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}