git.go

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