ce5ab220112d85a0b77c78fe32fee1d0bad71cea
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
2index de16e3e89e0c14e8e040d7b1140375ca4f04dd4a..a24fc6b33c20ee7e1e83147beb6112f7f0b368a2 100644
3--- a/cmd/soft/hook.go
4+++ b/cmd/soft/hook.go
5@@ -1,15 +1,23 @@
6 package main
7
8 import (
9+ "bufio"
10+ "bytes"
11+ "context"
12 "fmt"
13 "os"
14- "path/filepath"
15 "strings"
16
17- "github.com/charmbracelet/keygen"
18+ "github.com/charmbracelet/soft-serve/server/backend"
19+ "github.com/charmbracelet/soft-serve/server/backend/sqlite"
20 "github.com/charmbracelet/soft-serve/server/config"
21+ "github.com/charmbracelet/soft-serve/server/hooks"
22 "github.com/spf13/cobra"
23- gossh "golang.org/x/crypto/ssh"
24+)
25+
26+var (
27+ confixCtxKey = "config"
28+ backendCtxKey = "backend"
29 )
30
31 var (
32@@ -20,94 +28,113 @@ var (
33 Short: "Run git server hooks",
34 Long: "Handles Soft Serve git server hooks.",
35 Hidden: true,
36- RunE: func(_ *cobra.Command, args []string) error {
37- c, s, err := commonInit()
38+ PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
39+ cfg, err := config.ParseConfig(configPath)
40 if err != nil {
41- return err
42+ return fmt.Errorf("could not parse config: %w", err)
43 }
44- defer c.Close() //nolint:errcheck
45- defer s.Close() //nolint:errcheck
46- s.Stdin = os.Stdin
47- s.Stdout = os.Stdout
48- s.Stderr = os.Stderr
49- cmd := fmt.Sprintf("hook %s", strings.Join(args, " "))
50- if err := s.Run(cmd); err != nil {
51- return err
52+
53+ // Set up the backend
54+ // TODO: support other backends
55+ sb, err := sqlite.NewSqliteBackend(cmd.Context(), cfg)
56+ if err != nil {
57+ return fmt.Errorf("failed to create sqlite backend: %w", err)
58 }
59+
60+ cfg = cfg.WithBackend(sb)
61+
62+ cmd.SetContext(context.WithValue(cmd.Context(), confixCtxKey, cfg))
63+ cmd.SetContext(context.WithValue(cmd.Context(), backendCtxKey, sb))
64+
65 return nil
66 },
67 }
68-)
69
70-func init() {
71- hookCmd.PersistentFlags().StringVarP(&configPath, "config", "c", "", "path to config file")
72-}
73+ hooksRunE = func(cmd *cobra.Command, args []string) error {
74+ cfg := cmd.Context().Value(confixCtxKey).(*config.Config)
75+ hks := cfg.Backend.(backend.Hooks)
76
77-// TODO: use ssh controlmaster
78-func commonInit() (c *gossh.Client, s *gossh.Session, err error) {
79- cfg, err := config.ParseConfig(configPath)
80- if err != nil {
81- return
82- }
83+ // This is set in the server before invoking git-receive-pack/git-upload-pack
84+ repoName := os.Getenv("SOFT_SERVE_REPO_NAME")
85
86- // Git runs the hook within the repository's directory.
87- // Get the working directory to determine the repository name.
88- wd, err := os.Getwd()
89- if err != nil {
90- return
91- }
92+ in := cmd.InOrStdin()
93+ out := cmd.OutOrStdout()
94+ err := cmd.ErrOrStderr()
95
96- rs, err := filepath.Abs(filepath.Join(cfg.DataPath, "repos"))
97- if err != nil {
98- return
99- }
100+ cmdName := cmd.Name()
101+ switch cmdName {
102+ case hooks.PreReceiveHook, hooks.PostReceiveHook:
103+ var buf bytes.Buffer
104+ opts := make([]backend.HookArg, 0)
105diff --git a/server/backend/backend.go b/server/backend/backend.go
106index ae243d1a1e6232c09f048b5d8519f9bd3268b801..308f342794b03cc0d416c0530d5529b915b16695 100644
107--- a/server/backend/backend.go
108+++ b/server/backend/backend.go
109@@ -16,6 +16,7 @@ type Backend interface {
110 RepositoryAccess
111 UserStore
112 UserAccess
113+ Hooks
114 }
115
116 // ParseAuthorizedKey parses an authorized key string into a public key.
117diff --git a/server/backend/hooks.go b/server/backend/hooks.go
118new file mode 100644
119index 0000000000000000000000000000000000000000..ba130a30195a734a8b0626524dd6e9c9c9a0c056
120--- /dev/null
121+++ b/server/backend/hooks.go
122@@ -0,0 +1,20 @@
123+package backend
124+
125+import (
126+ "io"
127+)
128+
129+// HookArg is an argument to a git hook.
130+type HookArg struct {
131+ OldSha string
132+ NewSha string
133+ RefName string
134+}
135+
136+// Hooks provides an interface for git server-side hooks.
137+type Hooks interface {
138+ PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
139+ Update(stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
140+ PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
141+ PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string)
142+}
143diff --git a/server/backend/repo.go b/server/backend/repo.go
144index 7172d71ee8bf575654c3549ff10f87ec7e5194bc..8d7e9cdeffa516b70c276c383b4d78746ead6876 100644
145--- a/server/backend/repo.go
146+++ b/server/backend/repo.go
147@@ -29,8 +29,6 @@ type RepositoryStore interface {
148 DeleteRepository(name string) error
149 // RenameRepository renames a repository.
150 RenameRepository(oldName, newName string) error
151- // InitializeHooks initializes the hooks for the given repository.
152- InitializeHooks(repo string) error
153 }
154
155 // RepositoryMetadata is an interface for managing repository metadata.
156diff --git a/server/backend/sqlite/hooks.go b/server/backend/sqlite/hooks.go
157new file mode 100644
158index 0000000000000000000000000000000000000000..cf82c2c009a3b72415d0ba09b72152521eabe823
159--- /dev/null
160+++ b/server/backend/sqlite/hooks.go
161@@ -0,0 +1,64 @@
162+package sqlite
163+
164+import (
165+ "io"
166+ "sync"
167+
168+ "github.com/charmbracelet/log"
169+ "github.com/charmbracelet/soft-serve/server/backend"
170+)
171+
172+// PostReceive is called by the git post-receive hook.
173+//
174+// It implements Hooks.
175+func (d *SqliteBackend) PostReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
176+ log.WithPrefix("backend.sqlite.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
177+}
178+
179+// PreReceive is called by the git pre-receive hook.
180+//
181+// It implements Hooks.
182+func (d *SqliteBackend) PreReceive(stdout io.Writer, stderr io.Writer, repo string, args []backend.HookArg) {
183+ log.WithPrefix("backend.sqlite.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
184+}
185+
186+// Update is called by the git update hook.
187+//
188+// It implements Hooks.
189+func (d *SqliteBackend) Update(stdout io.Writer, stderr io.Writer, repo string, arg backend.HookArg) {
190+ log.WithPrefix("backend.sqlite.hooks").Debug("update hook called", "repo", repo, "arg", arg)
191+}
192+
193+// PostUpdate is called by the git post-update hook.
194+//
195+// It implements Hooks.
196+func (d *SqliteBackend) PostUpdate(stdout io.Writer, stderr io.Writer, repo string, args ...string) {
197+ log.WithPrefix("backend.sqlite.hooks").Debug("post-update hook called", "repo", repo, "args", args)
198+
199+ var wg sync.WaitGroup
200+
201+ // Update server info
202+ wg.Add(1)
203+ go func() {
204+ defer wg.Done()
205+
206+ rr, err := d.Repository(repo)
207+ if err != nil {
208+ log.WithPrefix("backend.sqlite.hooks").Error("error getting repository", "repo", repo, "err", err)
209+ return
210+ }
211+
212+ r, err := rr.Open()
213+ if err != nil {
214+ log.WithPrefix("backend.sqlite.hooks").Error("error opening repository", "repo", repo, "err", err)
215+ return
216+ }
217+
218+ if err := r.UpdateServerInfo(); err != nil {
219+ log.WithPrefix("backend.sqlite.hooks").Error("error updating server-info", "repo", repo, "err", err)
220+ return
221+ }
222+ }()
223+
224+ wg.Wait()
225+}
226diff --git a/server/backend/sqlite/sqlite.go b/server/backend/sqlite/sqlite.go
227index 382c99ff3e32005802de520ab5b6dcd273f05303..0720a09710bed98a2a3062482740ae3ddaafa050 100644
228--- a/server/backend/sqlite/sqlite.go
229+++ b/server/backend/sqlite/sqlite.go
230@@ -1,18 +1,17 @@
231 package sqlite
232
233 import (
234- "bytes"
235 "context"
236 "fmt"
237 "os"
238 "path/filepath"
239 "strings"
240- "text/template"
241
242 "github.com/charmbracelet/log"
243 "github.com/charmbracelet/soft-serve/git"
244 "github.com/charmbracelet/soft-serve/server/backend"
245 "github.com/charmbracelet/soft-serve/server/config"
246+ "github.com/charmbracelet/soft-serve/server/hooks"
247 "github.com/charmbracelet/soft-serve/server/utils"
248 "github.com/jmoiron/sqlx"
249 _ "modernc.org/sqlite"
250@@ -165,7 +164,7 @@ func (d *SqliteBackend) CreateRepository(name string, opts backend.RepositoryOpt
251 db: d.db,
252 }
253
254- return r, d.InitializeHooks(name)
255+ return r, d.initRepo(name)
256 }
257
258 // ImportRepository imports a repository from remote.
259@@ -186,7 +185,7 @@ func (d *SqliteBackend) ImportRepository(name string, remote string, opts backen
260 Envs: []string{
261 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
262 filepath.Join(d.cfg.DataPath, "ssh", "known_hosts"),
263- d.cfg.Internal.ClientKeyPath,
264+ d.cfg.SSH.ClientKeyPath,
265 ),
266 },
267 },
268@@ -551,157 +550,8 @@ func (d *SqliteBackend) RemoveCollaborator(repo string, username string) error {
269 )
270 }
271
272-var (
273- hookNames = []string{"pre-receive", "update", "post-update", "post-receive"}
274- hookTpls = []string{
275- // for pre-receive
276- `#!/usr/bin/env bash
277-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
278-data=$(cat)
279-exitcodes=""
280-hookname=$(basename $0)
281-GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
282-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
283- test -x "${hook}" && test -f "${hook}" || continue
284- echo "${data}" | "${hook}"
285- exitcodes="${exitcodes} $?"
286-done
287-for i in ${exitcodes}; do
288- [ ${i} -eq 0 ] || exit ${i}
289-done
290-`,
291-
292- // for update
293- `#!/usr/bin/env bash
294-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
295-exitcodes=""
296-hookname=$(basename $0)
297-GIT_DIR=${GIT_DIR:-$(dirname $0/..)}
298-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
299- test -x "${hook}" && test -f "${hook}" || continue
300- "${hook}" $1 $2 $3
301- exitcodes="${exitcodes} $?"
302-done
303-for i in ${exitcodes}; do
304- [ ${i} -eq 0 ] || exit ${i}
305-done
306-`,
307-
308- // for post-update
309- `#!/usr/bin/env bash
310-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
311-data=$(cat)
312-exitcodes=""
313-hookname=$(basename $0)
314-GIT_DIR=${GIT_DIR:-$(dirname $0)/..}
315-for hook in ${GIT_DIR}/hooks/${hookname}.d/*; do
316- test -x "${hook}" && test -f "${hook}" || continue
317- "${hook}" $@
318- exitcodes="${exitcodes} $?"
319-done
320-for i in ${exitcodes}; do
321- [ ${i} -eq 0 ] || exit ${i}
322-done
323-`,
324-
325- // for post-receive
326- `#!/usr/bin/env bash
327-# AUTO GENERATED BY SOFT SERVE, DO NOT MODIFY
328-data=$(cat)
329-exitcodes=""
330diff --git a/server/config/config.go b/server/config/config.go
331index 7c598548430b8f846cea8d1e86bc75892ff44670..7bd696e0e0d18220f3726dea9d781635b6f24fec 100644
332--- a/server/config/config.go
333+++ b/server/config/config.go
334@@ -25,6 +25,9 @@ type SSHConfig struct {
335 // KeyPath is the path to the SSH server's private key.
336 KeyPath string `env:"KEY_PATH" yaml:"key_path"`
337
338+ // ClientKeyPath is the path to the server's client private key.
339+ ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
340+
341 // MaxTimeout is the maximum number of seconds a connection can take.
342 MaxTimeout int `env:"MAX_TIMEOUT" yaml:"max_timeout"`
343
344@@ -68,22 +71,6 @@ type StatsConfig struct {
345 ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
346 }
347
348-// InternalConfig is the configuration for the internal server.
349-// This is used for internal communication between the Soft Serve client and server.
350-type InternalConfig struct {
351- // ListenAddr is the address on which the internal server will listen.
352- ListenAddr string `env:"LISTEN_ADDR" yaml:"listen_addr"`
353-
354- // KeyPath is the path to the SSH server's host private key.
355- KeyPath string `env:"KEY_PATH" yaml:"key_path"`
356-
357- // InternalKeyPath is the path to the server's internal private key.
358- InternalKeyPath string `env:"INTERNAL_KEY_PATH" yaml:"internal_key_path"`
359-
360- // ClientKeyPath is the path to the server's client private key.
361- ClientKeyPath string `env:"CLIENT_KEY_PATH" yaml:"client_key_path"`
362-}
363-
364 // Config is the configuration for Soft Serve.
365 type Config struct {
366 // Name is the name of the server.
367@@ -101,9 +88,6 @@ type Config struct {
368 // Stats is the configuration for the stats server.
369 Stats StatsConfig `envPrefix:"STATS_" yaml:"stats"`
370
371- // Internal is the configuration for the internal server.
372- Internal InternalConfig `envPrefix:"INTERNAL_" yaml:"internal"`
373-
374 // InitialAdminKeys is a list of public keys that will be added to the list of admins.
375 InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
376
377@@ -120,11 +104,12 @@ func parseConfig(path string) (*Config, error) {
378 Name: "Soft Serve",
379 DataPath: dataPath,
380 SSH: SSHConfig{
381- ListenAddr: ":23231",
382- PublicURL: "ssh://localhost:23231",
383- KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
384- MaxTimeout: 0,
385- IdleTimeout: 0,
386+ ListenAddr: ":23231",
387+ PublicURL: "ssh://localhost:23231",
388+ KeyPath: filepath.Join("ssh", "soft_serve_host_ed25519"),
389+ ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
390+ MaxTimeout: 0,
391+ IdleTimeout: 0,
392 },
393 Git: GitConfig{
394 ListenAddr: ":9418",
395@@ -139,12 +124,6 @@ func parseConfig(path string) (*Config, error) {
396 Stats: StatsConfig{
397 ListenAddr: "localhost:23233",
398 },
399- Internal: InternalConfig{
400- ListenAddr: "localhost:23230",
401- KeyPath: filepath.Join("ssh", "soft_serve_internal_host_ed25519"),
402- InternalKeyPath: filepath.Join("ssh", "soft_serve_internal_ed25519"),
403- ClientKeyPath: filepath.Join("ssh", "soft_serve_client_ed25519"),
404- },
405 }
406
407 f, err := os.Open(path)
408@@ -260,16 +239,8 @@ func (c *Config) validate() error {
409 c.SSH.KeyPath = filepath.Join(c.DataPath, c.SSH.KeyPath)
410 }
411
412- if c.Internal.KeyPath != "" && !filepath.IsAbs(c.Internal.KeyPath) {
413- c.Internal.KeyPath = filepath.Join(c.DataPath, c.Internal.KeyPath)
414- }
415-
416- if c.Internal.ClientKeyPath != "" && !filepath.IsAbs(c.Internal.ClientKeyPath) {
417- c.Internal.ClientKeyPath = filepath.Join(c.DataPath, c.Internal.ClientKeyPath)
418- }
419-
420- if c.Internal.InternalKeyPath != "" && !filepath.IsAbs(c.Internal.InternalKeyPath) {
421- c.Internal.InternalKeyPath = filepath.Join(c.DataPath, c.Internal.InternalKeyPath)
422+ if c.SSH.ClientKeyPath != "" && !filepath.IsAbs(c.SSH.ClientKeyPath) {
423+ c.SSH.ClientKeyPath = filepath.Join(c.DataPath, c.SSH.ClientKeyPath)
424 }
425
426 if c.HTTP.TLSKeyPath != "" && !filepath.IsAbs(c.HTTP.TLSKeyPath) {
427@@ -298,7 +269,7 @@ func parseAuthKeys(aks []string) []ssh.PublicKey {
428 return pks
429 }
430
431-// AdminKeys returns the admin keys including the internal api key.
432+// AdminKeys returns the server admin keys.
433 func (c *Config) AdminKeys() []ssh.PublicKey {
434diff --git a/server/config/file.go b/server/config/file.go
435index eb462ae7061b7477ab999802b5cb9efa212eab11..1869dd87fcfd72aca7c71fb50cf6a19e49231f9e 100644
436--- a/server/config/file.go
437+++ b/server/config/file.go
438@@ -24,6 +24,10 @@ ssh:
439 # The path to the SSH server's private key.
440 key_path: "{{ .SSH.KeyPath }}"
441
442+ # The path to the server's client private key. This key will be used to
443+ # authenticate the server to make git requests to ssh remotes.
444+ client_key_path: "{{ .Internal.ClientKeyPath }}"
445+
446 # The maximum number of seconds a connection can take.
447 # A value of 0 means no timeout.
448 max_timeout: {{ .SSH.MaxTimeout }}
449@@ -68,22 +72,6 @@ stats:
450 # The address on which the stats server will listen.
451 listen_addr: "{{ .Stats.ListenAddr }}"
452
453-# The internal server configuration.
454-internal:
455- # The address on which the internal server will listen.
456- listen_addr: "{{ .Internal.ListenAddr }}"
457-
458- # The path to the Internal server's host private key.
459- key_path: "{{ .Internal.KeyPath }}"
460-
461- # The path to the Internal server's client private key.
462- # This key will be used to authenticate the server to make git requests to
463- # ssh remotes.
464- client_key_path: "{{ .Internal.ClientKeyPath }}"
465-
466- # The path to the Internal server's internal api private key.
467- internal_key_path: "{{ .Internal.InternalKeyPath }}"
468-
469 # Additional admin keys.
470 #initial_admin_keys:
471 # - "ssh-rsa AAAAB3NzaC1yc2..."
472diff --git a/server/daemon/daemon.go b/server/daemon/daemon.go
473index 1a10c8c3bc204183e9362f74a0da948e8e816621..c3e08381502b6571fb4d3595627790680d053965 100644
474--- a/server/daemon/daemon.go
475+++ b/server/daemon/daemon.go
476@@ -253,7 +253,13 @@ func (d *GitDaemon) handleClient(conn net.Conn) {
477 return
478 }
479
480- if err := gitPack(c, c, c, filepath.Join(reposDir, repo)); err != nil {
481+ // Environment variables to pass down to git hooks.
482+ envs := []string{
483+ "SOFT_SERVE_REPO_NAME=" + name,
484+ "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
485+ }
486+
487+ if err := gitPack(ctx, c, c, c, filepath.Join(reposDir, repo), envs...); err != nil {
488 fatal(c, err)
489 return
490 }
491diff --git a/server/git/git.go b/server/git/git.go
492index 53c192d5468cbecf84ea8d740945e9cc8c0d5ce0..9a8b5ed9d401616151e7a47ced47a5ad9ff40dbc 100644
493--- a/server/git/git.go
494+++ b/server/git/git.go
495@@ -1,16 +1,19 @@
496 package git
497
498 import (
499+ "context"
500 "errors"
501 "fmt"
502 "io"
503 "os"
504+ "os/exec"
505 "path/filepath"
506 "strings"
507
508 "github.com/charmbracelet/log"
509 "github.com/charmbracelet/soft-serve/git"
510 "github.com/go-git/go-git/v5/plumbing/format/pktline"
511+ "golang.org/x/sync/errgroup"
512 )
513
514 var (
515@@ -42,7 +45,7 @@ const (
516 )
517
518 // UploadPack runs the git upload-pack protocol against the provided repo.
519-func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
520+func UploadPack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
521 exists, err := fileExists(repoDir)
522 if !exists {
523 return ErrInvalidRepo
524@@ -50,11 +53,11 @@ func UploadPack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error
525 if err != nil {
526 return err
527 }
528- return RunGit(in, out, er, "", UploadPackBin[4:], repoDir)
529+ return RunGit(ctx, in, out, er, "", envs, UploadPackBin[4:], repoDir)
530 }
531
532 // UploadArchive runs the git upload-archive protocol against the provided repo.
533-func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
534+func UploadArchive(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
535 exists, err := fileExists(repoDir)
536 if !exists {
537 return ErrInvalidRepo
538@@ -62,25 +65,77 @@ func UploadArchive(in io.Reader, out io.Writer, er io.Writer, repoDir string) er
539 if err != nil {
540 return err
541 }
542- return RunGit(in, out, er, "", UploadArchiveBin[4:], repoDir)
543+ return RunGit(ctx, in, out, er, "", envs, UploadArchiveBin[4:], repoDir)
544 }
545
546 // ReceivePack runs the git receive-pack protocol against the provided repo.
547-func ReceivePack(in io.Reader, out io.Writer, er io.Writer, repoDir string) error {
548- if err := RunGit(in, out, er, "", ReceivePackBin[4:], repoDir); err != nil {
549+func ReceivePack(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, repoDir string, envs ...string) error {
550+ if err := RunGit(ctx, in, out, er, "", envs, ReceivePackBin[4:], repoDir); err != nil {
551 return err
552 }
553- return EnsureDefaultBranch(in, out, er, repoDir)
554+ return EnsureDefaultBranch(ctx, in, out, er, repoDir)
555 }
556
557 // RunGit runs a git command in the given repo.
558-func RunGit(in io.Reader, out io.Writer, err io.Writer, dir string, args ...string) error {
559- c := git.NewCommand(args...)
560- return c.RunInDirWithOptions(dir, git.RunInDirOptions{
561- Stdin: in,
562- Stdout: out,
563- Stderr: err,
564+func RunGit(ctx context.Context, in io.Reader, out io.Writer, er io.Writer, dir string, envs []string, args ...string) error {
565+ logger := log.WithPrefix("server.git")
566+ c := exec.CommandContext(ctx, "git", args...)
567+ c.Dir = dir
568+ c.Env = append(c.Env, envs...)
569+ c.Env = append(c.Env, "SOFT_SERVE_DEBUG="+os.Getenv("SOFT_SERVE_DEBUG"))
570+ c.Env = append(c.Env, "PATH="+os.Getenv("PATH"))
571+
572+ stdin, err := c.StdinPipe()
573+ if err != nil {
574+ logger.Error("failed to get stdin pipe", "err", err)
575+ return err
576+ }
577+
578+ stdout, err := c.StdoutPipe()
579+ if err != nil {
580+ logger.Error("failed to get stdout pipe", "err", err)
581+ return err
582+ }
583+
584+ stderr, err := c.StderrPipe()
585+ if err != nil {
586+ logger.Error("failed to get stderr pipe", "err", err)
587+ return err
588+ }
589+
590+ if err := c.Start(); err != nil {
591+ logger.Error("failed to start command", "err", err)
592+ return err
593+ }
594+
595diff --git a/server/hooks.go b/server/hooks.go
596deleted file mode 100644
597index 17cc4c83e108cd1f24e4e1f72c1b413d95dd294b..0000000000000000000000000000000000000000
598--- a/server/hooks.go
599+++ /dev/null
600@@ -1,54 +0,0 @@
601-package server
602-
603-import (
604- "io"
605-
606- "github.com/charmbracelet/log"
607- "github.com/charmbracelet/soft-serve/server/hooks"
608-)
609-
610-var _ hooks.Hooks = (*Server)(nil)
611-
612-// PostReceive is called by the git post-receive hook.
613-//
614-// It implements Hooks.
615-func (*Server) PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
616- log.WithPrefix("server.hooks").Debug("post-receive hook called", "repo", repo, "args", args)
617-}
618-
619-// PreReceive is called by the git pre-receive hook.
620-//
621-// It implements Hooks.
622-func (*Server) PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []hooks.HookArg) {
623- log.WithPrefix("server.hooks").Debug("pre-receive hook called", "repo", repo, "args", args)
624-}
625-
626-// Update is called by the git update hook.
627-//
628-// It implements Hooks.
629-func (*Server) Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg hooks.HookArg) {
630- log.WithPrefix("server.hooks").Debug("update hook called", "repo", repo, "arg", arg)
631-}
632-
633-// PostUpdate is called by the git post-update hook.
634-//
635-// It implements Hooks.
636-func (s *Server) PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string) {
637- log.WithPrefix("server.hooks").Debug("post-update hook called", "repo", repo, "args", args)
638- rr, err := s.Config.Backend.Repository(repo)
639- if err != nil {
640- log.WithPrefix("server.hooks.post-update").Error("error getting repository", "repo", repo, "err", err)
641- return
642- }
643-
644- r, err := rr.Open()
645- if err != nil {
646- log.WithPrefix("server.hooks.post-update").Error("error opening repository", "repo", repo, "err", err)
647- return
648- }
649-
650- if err := r.UpdateServerInfo(); err != nil {
651- log.WithPrefix("server.hooks.post-update").Error("error updating server info", "repo", repo, "err", err)
652- return
653- }
654-}
655diff --git a/server/hooks/hooks.go b/server/hooks/hooks.go
656index 639950413741d4cebd47eebfcffc47b0e5107303..f7606bdd599d66ffcc0bb4cac24684fb1b1a3ca8 100644
657--- a/server/hooks/hooks.go
658+++ b/server/hooks/hooks.go
659@@ -1,18 +1,152 @@
660 package hooks
661
662-import "io"
663+import (
664+ "bytes"
665+ "context"
666+ "fmt"
667+ "os"
668+ "path/filepath"
669+ "text/template"
670
671-// HookArg is an argument to a git hook.
672-type HookArg struct {
673- OldSha string
674- NewSha string
675- RefName string
676-}
677+ "github.com/charmbracelet/log"
678+ "github.com/charmbracelet/soft-serve/server/config"
679+ "github.com/charmbracelet/soft-serve/server/utils"
680+)
681+
682+// The names of git server-side hooks.
683+const (
684+ PreReceiveHook = "pre-receive"
685+ UpdateHook = "update"
686+ PostReceiveHook = "post-receive"
687+ PostUpdateHook = "post-update"
688+)
689+
690+// GenerateHooks generates git server-side hooks for a repository. Currently, it supports the following hooks:
691+// - pre-receive
692+// - update
693+// - post-receive
694+// - post-update
695+//
696+// This function should be called by the backend when a repository is created.
697+// TODO: support context
698+func GenerateHooks(ctx context.Context, cfg *config.Config, repo string) error {
699+ repo = utils.SanitizeRepo(repo) + ".git"
700+ hooksPath := filepath.Join(cfg.DataPath, "repos", repo, "hooks")
701+ if err := os.MkdirAll(hooksPath, os.ModePerm); err != nil {
702+ return err
703+ }
704+
705+ ex, err := os.Executable()
706+ if err != nil {
707+ return err
708+ }
709+
710+ dp, err := filepath.Abs(cfg.DataPath)
711+ if err != nil {
712+ return fmt.Errorf("failed to get absolute path for data path: %w", err)
713+ }
714+
715+ cp := filepath.Join(dp, "config.yaml")
716+ // Add extra environment variables to the hooks here.
717+ envs := []string{}
718+
719+ for _, hook := range []string{
720+ PreReceiveHook,
721+ UpdateHook,
722+ PostReceiveHook,
723+ PostUpdateHook,
724+ } {
725+ var data bytes.Buffer
726+ var args string
727+
728+ // Hooks script/directory path
729+ hp := filepath.Join(hooksPath, hook)
730+
731+ // Write the hooks primary script
732+ if err := os.WriteFile(hp, []byte(hookTemplate), os.ModePerm); err != nil {
733+ return err
734+ }
735
736-// Hooks provides an interface for git server-side hooks.
737-type Hooks interface {
738- PreReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
739- Update(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, arg HookArg)
740- PostReceive(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args []HookArg)
741- PostUpdate(stdin io.Reader, stdout io.Writer, stderr io.Writer, repo string, args ...string)
742+ // Create ${hook}.d directory.
743+ hp += ".d"
744+ if err := os.MkdirAll(hp, os.ModePerm); err != nil {
745+ return err
746+ }
747+
748+ switch hook {
749+ case UpdateHook:
750+ args = "$1 $2 $3"
751+ case PostUpdateHook:
752+ args = "$@"
753+ }
754+
755+ if err := hooksTmpl.Execute(&data, struct {
756+ Executable string
757+ Config string
758+ Envs []string
759diff --git a/server/internal/cmd.go b/server/internal/cmd.go
760deleted file mode 100644
761index 62709bed11248fa73e540decbb53ca03a437921e..0000000000000000000000000000000000000000
762--- a/server/internal/cmd.go
763+++ /dev/null
764@@ -1,84 +0,0 @@
765-package internal
766-
767-import (
768- "context"
769-
770- "github.com/charmbracelet/soft-serve/server/config"
771- "github.com/charmbracelet/soft-serve/server/hooks"
772- "github.com/charmbracelet/ssh"
773- "github.com/charmbracelet/wish"
774- "github.com/spf13/cobra"
775-)
776-
777-var (
778- hooksCtxKey = "hooks"
779- sessionCtxKey = "session"
780- configCtxKey = "config"
781-)
782-
783-// rootCommand is the root command for the server.
784-func rootCommand(cfg *config.Config, s ssh.Session) *cobra.Command {
785- rootCmd := &cobra.Command{
786- Short: "Soft Serve internal API.",
787- SilenceUsage: true,
788- }
789-
790- rootCmd.SetIn(s)
791- rootCmd.SetOut(s)
792- rootCmd.SetErr(s)
793- rootCmd.CompletionOptions.DisableDefaultCmd = true
794-
795- rootCmd.AddCommand(
796- hookCommand(),
797- )
798-
799- return rootCmd
800-}
801-
802-// Middleware returns the middleware for the server.
803-func (i *InternalServer) Middleware(hooks hooks.Hooks) wish.Middleware {
804- return func(sh ssh.Handler) ssh.Handler {
805- return func(s ssh.Session) {
806- _, _, active := s.Pty()
807- if active {
808- return
809- }
810-
811- // Ignore git server commands.
812- args := s.Command()
813- if len(args) > 0 {
814- if args[0] == "git-receive-pack" ||
815- args[0] == "git-upload-pack" ||
816- args[0] == "git-upload-archive" {
817- return
818- }
819- }
820-
821- ctx := context.WithValue(s.Context(), hooksCtxKey, hooks)
822- ctx = context.WithValue(ctx, sessionCtxKey, s)
823- ctx = context.WithValue(ctx, configCtxKey, i.cfg)
824-
825- rootCmd := rootCommand(i.cfg, s)
826- rootCmd.SetArgs(args)
827- if len(args) == 0 {
828- // otherwise it'll default to os.Args, which is not what we want.
829- rootCmd.SetArgs([]string{"--help"})
830- }
831- rootCmd.SetIn(s)
832- rootCmd.SetOut(s)
833- rootCmd.CompletionOptions.DisableDefaultCmd = true
834- rootCmd.SetErr(s.Stderr())
835- if err := rootCmd.ExecuteContext(ctx); err != nil {
836- _ = s.Exit(1)
837- }
838- sh(s)
839- }
840- }
841-}
842-
843-func fromContext(cmd *cobra.Command) (*config.Config, ssh.Session) {
844- ctx := cmd.Context()
845- cfg := ctx.Value(configCtxKey).(*config.Config)
846- s := ctx.Value(sessionCtxKey).(ssh.Session)
847- return cfg, s
848-}
849diff --git a/server/internal/hook.go b/server/internal/hook.go
850deleted file mode 100644
851index a4d4ecbaf7254f85ea798f0026009b8154724ba7..0000000000000000000000000000000000000000
852--- a/server/internal/hook.go
853+++ /dev/null
854@@ -1,138 +0,0 @@
855-package internal
856-
857-import (
858- "bufio"
859- "fmt"
860- "strings"
861-
862- "github.com/charmbracelet/keygen"
863- "github.com/charmbracelet/log"
864- "github.com/charmbracelet/soft-serve/server/backend"
865- "github.com/charmbracelet/soft-serve/server/errors"
866- "github.com/charmbracelet/soft-serve/server/hooks"
867- "github.com/charmbracelet/ssh"
868- "github.com/spf13/cobra"
869-)
870-
871-// hookCommand handles Soft Serve internal API git hook requests.
872-func hookCommand() *cobra.Command {
873- preReceiveCmd := &cobra.Command{
874- Use: "pre-receive",
875- Short: "Run git pre-receive hook",
876- RunE: func(cmd *cobra.Command, args []string) error {
877- _, s := fromContext(cmd)
878- hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
879- repoName := getRepoName(s)
880- opts := make([]hooks.HookArg, 0)
881- scanner := bufio.NewScanner(s)
882- for scanner.Scan() {
883- fields := strings.Fields(scanner.Text())
884- if len(fields) != 3 {
885- return fmt.Errorf("invalid pre-receive hook input: %s", scanner.Text())
886- }
887- opts = append(opts, hooks.HookArg{
888- OldSha: fields[0],
889- NewSha: fields[1],
890- RefName: fields[2],
891- })
892- }
893- hks.PreReceive(s, s, s.Stderr(), repoName, opts)
894- return nil
895- },
896- }
897-
898- updateCmd := &cobra.Command{
899- Use: "update",
900- Short: "Run git update hook",
901- Args: cobra.ExactArgs(3),
902- RunE: func(cmd *cobra.Command, args []string) error {
903- _, s := fromContext(cmd)
904- hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
905- repoName := getRepoName(s)
906- hks.Update(s, s, s.Stderr(), repoName, hooks.HookArg{
907- RefName: args[0],
908- OldSha: args[1],
909- NewSha: args[2],
910- })
911- return nil
912- },
913- }
914-
915- postReceiveCmd := &cobra.Command{
916- Use: "post-receive",
917- Short: "Run git post-receive hook",
918- RunE: func(cmd *cobra.Command, _ []string) error {
919- _, s := fromContext(cmd)
920- hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
921- repoName := getRepoName(s)
922- opts := make([]hooks.HookArg, 0)
923- scanner := bufio.NewScanner(s)
924- for scanner.Scan() {
925- fields := strings.Fields(scanner.Text())
926- if len(fields) != 3 {
927- return fmt.Errorf("invalid post-receive hook input: %s", scanner.Text())
928- }
929- opts = append(opts, hooks.HookArg{
930- OldSha: fields[0],
931- NewSha: fields[1],
932- RefName: fields[2],
933- })
934- }
935- hks.PostReceive(s, s, s.Stderr(), repoName, opts)
936- return nil
937- },
938- }
939-
940- postUpdateCmd := &cobra.Command{
941- Use: "post-update",
942- Short: "Run git post-update hook",
943- RunE: func(cmd *cobra.Command, args []string) error {
944- _, s := fromContext(cmd)
945- hks := cmd.Context().Value(hooksCtxKey).(hooks.Hooks)
946- repoName := getRepoName(s)
947- hks.PostUpdate(s, s, s.Stderr(), repoName, args...)
948- return nil
949- },
950- }
951-
952- hookCmd := &cobra.Command{
953- Use: "hook",
954diff --git a/server/internal/internal.go b/server/internal/internal.go
955deleted file mode 100644
956index 52114c4ca0afbfdc2fd1da7fa81d914c68139777..0000000000000000000000000000000000000000
957--- a/server/internal/internal.go
958+++ /dev/null
959@@ -1,86 +0,0 @@
960-package internal
961-
962-import (
963- "context"
964- "fmt"
965-
966- "github.com/charmbracelet/keygen"
967- "github.com/charmbracelet/soft-serve/server/backend"
968- "github.com/charmbracelet/soft-serve/server/config"
969- "github.com/charmbracelet/soft-serve/server/hooks"
970- "github.com/charmbracelet/ssh"
971- "github.com/charmbracelet/wish"
972-)
973-
974-// InternalServer is a internal interface to communicate with the server.
975-type InternalServer struct {
976- cfg *config.Config
977- s *ssh.Server
978- kp *keygen.SSHKeyPair
979- ckp *keygen.SSHKeyPair
980-}
981-
982-// NewInternalServer returns a new internal server.
983-func NewInternalServer(cfg *config.Config, hooks hooks.Hooks) (*InternalServer, error) {
984- i := &InternalServer{cfg: cfg}
985-
986- // Create internal key.
987- ikp, err := keygen.New(
988- cfg.Internal.InternalKeyPath,
989- keygen.WithKeyType(keygen.Ed25519),
990- keygen.WithWrite(),
991- )
992- if err != nil {
993- return nil, fmt.Errorf("internal key: %w", err)
994- }
995-
996- i.kp = ikp
997-
998- // Create client key.
999- ckp, err := keygen.New(
1000- cfg.Internal.ClientKeyPath,
1001- keygen.WithKeyType(keygen.Ed25519),
1002- keygen.WithWrite(),
1003- )
1004- if err != nil {
1005- return nil, fmt.Errorf("client key: %w", err)
1006- }
1007-
1008- i.ckp = ckp
1009-
1010- s, err := wish.NewServer(
1011- wish.WithAddress(cfg.Internal.ListenAddr),
1012- wish.WithHostKeyPath(cfg.Internal.KeyPath),
1013- wish.WithPublicKeyAuth(i.PublicKeyHandler),
1014- wish.WithMiddleware(
1015- i.Middleware(hooks),
1016- ),
1017- )
1018- if err != nil {
1019- return nil, fmt.Errorf("wish: %w", err)
1020- }
1021-
1022- i.s = s
1023-
1024- return i, nil
1025-}
1026-
1027-// PublicKeyHandler handles public key authentication.
1028-func (i *InternalServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool {
1029- return backend.KeysEqual(i.kp.PublicKey(), pk)
1030-}
1031-
1032-// Start starts the internal server.
1033-func (i *InternalServer) Start() error {
1034- return i.s.ListenAndServe()
1035-}
1036-
1037-// Shutdown shuts down the internal server.
1038-func (i *InternalServer) Shutdown(ctx context.Context) error {
1039- return i.s.Shutdown(ctx)
1040-}
1041-
1042-// Close closes the internal server.
1043-func (i *InternalServer) Close() error {
1044- return i.s.Close()
1045-}
1046diff --git a/server/jobs.go b/server/jobs.go
1047index 37ecbeb36a33c540a38bfd5ace33615f0cce05ce..5f239be583961649353568d5048042e497f8bfb6 100644
1048--- a/server/jobs.go
1049+++ b/server/jobs.go
1050@@ -38,7 +38,7 @@ func mirrorJob(cfg *config.Config) func() {
1051 cmd.AddEnvs(
1052 fmt.Sprintf(`GIT_SSH_COMMAND=ssh -o UserKnownHostsFile="%s" -o StrictHostKeyChecking=no -i "%s"`,
1053 filepath.Join(cfg.DataPath, "ssh", "known_hosts"),
1054- cfg.Internal.ClientKeyPath,
1055+ cfg.SSH.ClientKeyPath,
1056 ),
1057 )
1058 if _, err := cmd.RunInDir(r.Path); err != nil {
1059diff --git a/server/server.go b/server/server.go
1060index ecc2c7c357897d71adc4610fb252ca357a291470..182b20f6662cb49c3a8655f84b5b06d806f047cd 100644
1061--- a/server/server.go
1062+++ b/server/server.go
1063@@ -13,7 +13,6 @@ import (
1064 "github.com/charmbracelet/soft-serve/server/config"
1065 "github.com/charmbracelet/soft-serve/server/cron"
1066 "github.com/charmbracelet/soft-serve/server/daemon"
1067- "github.com/charmbracelet/soft-serve/server/internal"
1068 sshsrv "github.com/charmbracelet/soft-serve/server/ssh"
1069 "github.com/charmbracelet/soft-serve/server/stats"
1070 "github.com/charmbracelet/soft-serve/server/web"
1071@@ -27,15 +26,14 @@ var (
1072
1073 // Server is the Soft Serve server.
1074 type Server struct {
1075- SSHServer *sshsrv.SSHServer
1076- GitDaemon *daemon.GitDaemon
1077- HTTPServer *web.HTTPServer
1078- StatsServer *stats.StatsServer
1079- InternalServer *internal.InternalServer
1080- Cron *cron.CronScheduler
1081- Config *config.Config
1082- Backend backend.Backend
1083- ctx context.Context
1084+ SSHServer *sshsrv.SSHServer
1085+ GitDaemon *daemon.GitDaemon
1086+ HTTPServer *web.HTTPServer
1087+ StatsServer *stats.StatsServer
1088+ Cron *cron.CronScheduler
1089+ Config *config.Config
1090+ Backend backend.Backend
1091+ ctx context.Context
1092 }
1093
1094 // NewServer returns a new *ssh.Server configured to serve Soft Serve. The SSH
1095@@ -84,11 +82,6 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) {
1096 return nil, fmt.Errorf("create stats server: %w", err)
1097 }
1098
1099- srv.InternalServer, err = internal.NewInternalServer(cfg, srv)
1100- if err != nil {
1101- return nil, fmt.Errorf("create internal server: %w", err)
1102- }
1103-
1104 return srv, nil
1105 }
1106
1107@@ -143,13 +136,6 @@ func (s *Server) Start() error {
1108 s.Cron.Start()
1109 return nil
1110 })
1111- errg.Go(func() error {
1112- logger.Print("Starting internal server", "addr", s.Config.Internal.ListenAddr)
1113- if err := start(ctx, s.InternalServer.Start); !errors.Is(err, http.ErrServerClosed) {
1114- return err
1115- }
1116- return nil
1117- })
1118 return errg.Wait()
1119 }
1120
1121@@ -172,9 +158,6 @@ func (s *Server) Shutdown(ctx context.Context) error {
1122 s.Cron.Stop()
1123 return nil
1124 })
1125- errg.Go(func() error {
1126- return s.InternalServer.Shutdown(ctx)
1127- })
1128 return errg.Wait()
1129 }
1130
1131@@ -189,6 +172,5 @@ func (s *Server) Close() error {
1132 s.Cron.Stop()
1133 return nil
1134 })
1135- errg.Go(s.InternalServer.Close)
1136 return errg.Wait()
1137 }
1138diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1139index a407d8fc929541f26ff18baba0e511163a247775..419ab241d2e335f8b6c4ca8ae6330177c77dce4f 100644
1140--- a/server/ssh/ssh.go
1141+++ b/server/ssh/ssh.go
1142@@ -189,6 +189,13 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1143 return
1144 }
1145
1146+ // Environment variables to pass down to git hooks.
1147+ envs := []string{
1148+ "SOFT_SERVE_REPO_NAME=" + name,
1149+ "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1150+ "SOFT_SERVE_PUBLIC_KEY=" + ak,
1151+ }
1152+
1153 logger.Debug("git middleware", "cmd", gc, "access", access.String())
1154 repoDir := filepath.Join(reposDir, repo)
1155 switch gc {
1156@@ -205,7 +212,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1157 }
1158 createRepoCounter.WithLabelValues(ak, s.User(), name).Inc()
1159 }
1160- if err := git.ReceivePack(s, s, s.Stderr(), repoDir); err != nil {
1161+ if err := git.ReceivePack(s.Context(), s, s, s.Stderr(), repoDir, envs...); err != nil {
1162 sshFatal(s, git.ErrSystemMalfunction)
1163 }
1164 receivePackCounter.WithLabelValues(ak, s.User(), name).Inc()
1165@@ -223,7 +230,7 @@ func (s *SSHServer) Middleware(cfg *config.Config) wish.Middleware {
1166 counter = uploadArchiveCounter
1167 }
1168
1169- err := gitPack(s, s, s.Stderr(), repoDir)
1170+ err := gitPack(s.Context(), s, s, s.Stderr(), repoDir, envs...)
1171 if errors.Is(err, git.ErrInvalidRepo) {
1172 sshFatal(s, git.ErrInvalidRepo)
1173 } else if err != nil {
1174diff --git a/server/test/test.go b/server/test/test.go
1175index 2dfe85c97dea227186565a04ef3ad86b31e120f8..a9d4d0eca7ac48899385dd23254c66f1249e2855 100644
1176--- a/server/test/test.go
1177+++ b/server/test/test.go
1178@@ -2,6 +2,8 @@ package test
1179
1180 import "net"
1181
1182+// RandomPort returns a random port number.
1183+// This is mainly used for testing.
1184 func RandomPort() int {
1185 addr, _ := net.Listen("tcp", ":0") //nolint:gosec
1186 _ = addr.Close()