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 envs = append(envs, cfg.Environ()...)
206
207 repoPath := filepath.Join(reposDir, repoDir)
208 service := git.Service(cmd.Name())
209 stdin := cmd.InOrStdin()
210 stdout := cmd.OutOrStdout()
211 stderr := cmd.ErrOrStderr()
212 scmd := git.ServiceCommand{
213 Stdin: stdin,
214 Stdout: stdout,
215 Stderr: stderr,
216 Env: envs,
217 Dir: repoPath,
218 }
219
220 switch service {
221 case git.ReceivePackService:
222 receivePackCounter.WithLabelValues(name).Inc()
223 defer func() {
224 receivePackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
225 }()
226 if accessLevel < access.ReadWriteAccess {
227 return git.ErrNotAuthed
228 }
229 if repo == nil {
230 if _, err := be.CreateRepository(ctx, name, user, proto.RepositoryOptions{Private: false}); err != nil {
231 log.Errorf("failed to create repo: %s", err)
232 return err
233 }
234 createRepoCounter.WithLabelValues(name).Inc()
235 }
236
237 if err := service.Handler(ctx, scmd); err != nil {
238 logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
239 defer func() {
240 if repo == nil {
241 // If the repo was created, but the request failed, delete it.
242 be.DeleteRepository(ctx, name) // nolint: errcheck
243 }
244 }()
245
246 return git.ErrSystemMalfunction
247 }
248
249 if err := git.EnsureDefaultBranch(ctx, scmd.Dir); err != nil {
250 logger.Error("failed to ensure default branch", "err", err, "repo", name)
251 return git.ErrSystemMalfunction
252 }
253
254 receivePackCounter.WithLabelValues(name).Inc()
255
256 return nil
257 case git.UploadPackService, git.UploadArchiveService:
258 if accessLevel < access.ReadOnlyAccess {
259 return git.ErrNotAuthed
260 }
261
262 if repo == nil {
263 return git.ErrInvalidRepo
264 }
265
266 switch service {
267 case git.UploadArchiveService:
268 uploadArchiveCounter.WithLabelValues(name).Inc()
269 defer func() {
270 uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
271 }()
272 default:
273 uploadPackCounter.WithLabelValues(name).Inc()
274 defer func() {
275 uploadPackSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
276 }()
277 }
278
279 err := service.Handler(ctx, scmd)
280 if errors.Is(err, git.ErrInvalidRepo) {
281 return git.ErrInvalidRepo
282 } else if err != nil {
283 logger.Error("failed to handle git service", "service", service, "err", err, "repo", name)
284 return git.ErrSystemMalfunction
285 }
286
287 return nil
288 case git.LFSTransferService, git.LFSAuthenticateService:
289 operation := args[1]
290 switch operation {
291 case lfs.OperationDownload:
292 if accessLevel < access.ReadOnlyAccess {
293 return git.ErrNotAuthed
294 }
295 case lfs.OperationUpload:
296 if accessLevel < access.ReadWriteAccess {
297 return git.ErrNotAuthed
298 }
299 default:
300 return git.ErrInvalidRequest
301 }
302
303 if repo == nil {
304 return git.ErrInvalidRepo
305 }
306
307 scmd.Args = []string{
308 name,
309 args[1],
310 }
311
312 switch service {
313 case git.LFSTransferService:
314 lfsTransferCounter.WithLabelValues(name, operation).Inc()
315 defer func() {
316 lfsTransferSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
317 }()
318 default:
319 lfsAuthenticateCounter.WithLabelValues(name, operation).Inc()
320 defer func() {
321 lfsAuthenticateSeconds.WithLabelValues(name, operation).Add(time.Since(start).Seconds())
322 }()
323 }
324
325 if err := service.Handler(ctx, scmd); err != nil {
326 logger.Error("failed to handle lfs service", "service", service, "err", err, "repo", name)
327 return git.ErrSystemMalfunction
328 }
329
330 return nil
331 }
332
333 return errors.New("unsupported git service")
334}