1package cmd
2
3import (
4 "errors"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "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}