git.go

  1package cmd
  2
  3import (
  4	"errors"
  5	"path/filepath"
  6	"strings"
  7	"time"
  8
  9	log "github.com/charmbracelet/log/v2"
 10	"github.com/charmbracelet/soft-serve/pkg/access"
 11	"github.com/charmbracelet/soft-serve/pkg/backend"
 12	"github.com/charmbracelet/soft-serve/pkg/config"
 13	"github.com/charmbracelet/soft-serve/pkg/git"
 14	"github.com/charmbracelet/soft-serve/pkg/lfs"
 15	"github.com/charmbracelet/soft-serve/pkg/proto"
 16	"github.com/charmbracelet/soft-serve/pkg/sshutils"
 17	"github.com/charmbracelet/soft-serve/pkg/utils"
 18	"github.com/prometheus/client_golang/prometheus"
 19	"github.com/prometheus/client_golang/prometheus/promauto"
 20	"github.com/spf13/cobra"
 21)
 22
 23var (
 24	uploadPackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 25		Namespace: "soft_serve",
 26		Subsystem: "git",
 27		Name:      "upload_pack_total",
 28		Help:      "The total number of git-upload-pack requests",
 29	}, []string{"repo"})
 30
 31	receivePackCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 32		Namespace: "soft_serve",
 33		Subsystem: "git",
 34		Name:      "receive_pack_total",
 35		Help:      "The total number of git-receive-pack requests",
 36	}, []string{"repo"})
 37
 38	uploadArchiveCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 39		Namespace: "soft_serve",
 40		Subsystem: "git",
 41		Name:      "upload_archive_total",
 42		Help:      "The total number of git-upload-archive requests",
 43	}, []string{"repo"})
 44
 45	lfsAuthenticateCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 46		Namespace: "soft_serve",
 47		Subsystem: "git",
 48		Name:      "lfs_authenticate_total",
 49		Help:      "The total number of git-lfs-authenticate requests",
 50	}, []string{"repo", "operation"})
 51
 52	lfsTransferCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 53		Namespace: "soft_serve",
 54		Subsystem: "git",
 55		Name:      "lfs_transfer_total",
 56		Help:      "The total number of git-lfs-transfer requests",
 57	}, []string{"repo", "operation"})
 58
 59	uploadPackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 60		Namespace: "soft_serve",
 61		Subsystem: "git",
 62		Name:      "upload_pack_seconds_total",
 63		Help:      "The total time spent on git-upload-pack requests",
 64	}, []string{"repo"})
 65
 66	receivePackSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 67		Namespace: "soft_serve",
 68		Subsystem: "git",
 69		Name:      "receive_pack_seconds_total",
 70		Help:      "The total time spent on git-receive-pack requests",
 71	}, []string{"repo"})
 72
 73	uploadArchiveSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 74		Namespace: "soft_serve",
 75		Subsystem: "git",
 76		Name:      "upload_archive_seconds_total",
 77		Help:      "The total time spent on git-upload-archive requests",
 78	}, []string{"repo"})
 79
 80	lfsAuthenticateSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 81		Namespace: "soft_serve",
 82		Subsystem: "git",
 83		Name:      "lfs_authenticate_seconds_total",
 84		Help:      "The total time spent on git-lfs-authenticate requests",
 85	}, []string{"repo", "operation"})
 86
 87	lfsTransferSeconds = promauto.NewCounterVec(prometheus.CounterOpts{
 88		Namespace: "soft_serve",
 89		Subsystem: "git",
 90		Name:      "lfs_transfer_seconds_total",
 91		Help:      "The total time spent on git-lfs-transfer requests",
 92	}, []string{"repo", "operation"})
 93
 94	createRepoCounter = promauto.NewCounterVec(prometheus.CounterOpts{
 95		Namespace: "soft_serve",
 96		Subsystem: "ssh",
 97		Name:      "create_repo_total",
 98		Help:      "The total number of create repo requests",
 99	}, []string{"repo"})
100)
101
102// GitUploadPackCommand returns a cobra command for git-upload-pack.
103func GitUploadPackCommand() *cobra.Command {
104	cmd := &cobra.Command{
105		Use:    "git-upload-pack REPO",
106		Short:  "Git upload pack",
107		Args:   cobra.ExactArgs(1),
108		Hidden: true,
109		RunE:   gitRunE,
110	}
111
112	return cmd
113}
114
115// GitUploadArchiveCommand returns a cobra command for git-upload-archive.
116func GitUploadArchiveCommand() *cobra.Command {
117	cmd := &cobra.Command{
118		Use:    "git-upload-archive REPO",
119		Short:  "Git upload archive",
120		Args:   cobra.ExactArgs(1),
121		Hidden: true,
122		RunE:   gitRunE,
123	}
124
125	return cmd
126}
127
128// GitReceivePackCommand returns a cobra command for git-receive-pack.
129func GitReceivePackCommand() *cobra.Command {
130	cmd := &cobra.Command{
131		Use:    "git-receive-pack REPO",
132		Short:  "Git receive pack",
133		Args:   cobra.ExactArgs(1),
134		Hidden: true,
135		RunE:   gitRunE,
136	}
137
138	return cmd
139}
140
141// GitLFSAuthenticateCommand returns a cobra command for git-lfs-authenticate.
142func GitLFSAuthenticateCommand() *cobra.Command {
143	cmd := &cobra.Command{
144		Use:    "git-lfs-authenticate REPO OPERATION",
145		Short:  "Git LFS authenticate",
146		Args:   cobra.ExactArgs(2),
147		Hidden: true,
148		RunE:   gitRunE,
149	}
150
151	return cmd
152}
153
154// GitLFSTransfer returns a cobra command for git-lfs-transfer.
155func GitLFSTransfer() *cobra.Command {
156	cmd := &cobra.Command{
157		Use:    "git-lfs-transfer REPO OPERATION",
158		Short:  "Git LFS transfer",
159		Args:   cobra.ExactArgs(2),
160		Hidden: true,
161		RunE:   gitRunE,
162	}
163
164	return cmd
165}
166
167func gitRunE(cmd *cobra.Command, args []string) error {
168	ctx := cmd.Context()
169	cfg := config.FromContext(ctx)
170	be := backend.FromContext(ctx)
171	logger := log.FromContext(ctx)
172	start := time.Now()
173
174	// repo should be in the form of "repo.git"
175	name := utils.SanitizeRepo(args[0])
176	pk := sshutils.PublicKeyFromContext(ctx)
177	ak := sshutils.MarshalAuthorizedKey(pk)
178	user := proto.UserFromContext(ctx)
179	accessLevel := be.AccessLevelForUser(ctx, name, user)
180	// git bare repositories should end in ".git"
181	// https://git-scm.com/docs/gitrepository-layout
182	repoDir := name + ".git"
183	reposDir := filepath.Join(cfg.DataPath, "repos")
184	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
185		return err
186	}
187
188	// Set repo in context
189	repo, _ := be.Repository(ctx, name)
190	ctx = proto.WithRepositoryContext(ctx, repo)
191
192	// Environment variables to pass down to git hooks.
193	envs := []string{
194		"SOFT_SERVE_REPO_NAME=" + name,
195		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
196		"SOFT_SERVE_PUBLIC_KEY=" + ak,
197		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
198	}
199
200	if user != nil {
201		envs = append(envs,
202			"SOFT_SERVE_USERNAME="+user.Username(),
203		)
204	}
205
206	envs = append(envs, cfg.Environ()...)
207
208	// Add GIT_PROTOCOL from session.
209	if sess := sshutils.SessionFromContext(ctx); sess != nil {
210		for _, env := range sess.Environ() {
211			if strings.HasPrefix(env, "GIT_PROTOCOL=") {
212				envs = append(envs, env)
213				break
214			}
215		}
216	}
217
218	repoPath := filepath.Join(reposDir, repoDir)
219	service := git.Service(cmd.Name())
220	stdin := cmd.InOrStdin()
221	stdout := cmd.OutOrStdout()
222	stderr := cmd.ErrOrStderr()
223	scmd := git.ServiceCommand{
224		Stdin:  stdin,
225		Stdout: stdout,
226		Stderr: stderr,
227		Env:    envs,
228		Dir:    repoPath,
229	}
230
231	switch service {
232	case git.ReceivePackService:
233		receivePackCounter.WithLabelValues(name).Inc()
234		defer func() {
235			receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
236		}()
237		if accessLevel < access.ReadWriteAccess {
238			return git.ErrNotAuthed
239		}
240		if repo == nil {
241			if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
242				log.Errorf("failed to create repo: %s", err)
243				return err
244			}
245			createRepoCounter.WithLabelValues(name).Inc()
246		}
247
248		if err := service.Handler(ctx, scmd); err != nil {
249			logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
250			defer func() {
251				if repo == nil {
252					// If the repo was created, but the request failed, delete it.
253					be.DeleteRepository(ctx, name) //nolint: errcheck
254				}
255			}()
256
257			return git.ErrSystemMalfunction
258		}
259
260		if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
261			logger.Error("failed to ensure default branch", "err", err, "repo", name)
262			return git.ErrSystemMalfunction
263		}
264
265		receivePackCounter.WithLabelValues(name).Inc()
266
267		return nil
268	case git.UploadPackService, git.UploadArchiveService:
269		if accessLevel < access.ReadOnlyAccess {
270			return git.ErrNotAuthed
271		}
272
273		if repo == nil {
274			return git.ErrInvalidRepo
275		}
276
277		switch service {
278		case git.UploadArchiveService:
279			uploadArchiveCounter.WithLabelValues(name).Inc()
280			defer func() {
281				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
282			}()
283		case git.UploadPackService, git.ReceivePackService, git.LFSTransferService:
284			uploadPackCounter.WithLabelValues(name).Inc()
285			defer func() {
286				uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
287			}()
288		default:
289			uploadPackCounter.WithLabelValues(name).Inc()
290			defer func() {
291				uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
292			}()
293		}
294
295		err := service.Handler(ctx, scmd)
296		if errors.Is(err, git.ErrInvalidRepo) {
297			return git.ErrInvalidRepo
298		} else if err != nil {
299			logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
300			return git.ErrSystemMalfunction
301		}
302
303		return nil
304	case git.LFSTransferService, git.LFSAuthenticateService:
305		operation := args[1]
306		switch operation {
307		case lfs.OperationDownload:
308			if accessLevel < access.ReadOnlyAccess {
309				return git.ErrNotAuthed
310			}
311		case lfs.OperationUpload:
312			if accessLevel < access.ReadWriteAccess {
313				return git.ErrNotAuthed
314			}
315		default:
316			return git.ErrInvalidRequest
317		}
318
319		if repo == nil {
320			return git.ErrInvalidRepo
321		}
322
323		scmd.Args = []string{
324			name,
325			args[1],
326		}
327
328		switch service {
329		case git.LFSTransferService:
330			lfsTransferCounter.WithLabelValues(name, operation).Inc()
331			defer func() {
332				lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
333			}()
334		case git.UploadPackService, git.UploadArchiveService, git.ReceivePackService:
335			lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
336			defer func() {
337				lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
338			}()
339		default:
340			lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
341			defer func() {
342				lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
343			}()
344		}
345
346		if err := service.Handler(ctx, scmd); err != nil {
347			logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name)
348			return git.ErrSystemMalfunction
349		}
350
351		return nil
352	}
353
354	return errors.New("unsupported git service")
355}