feat(serve): load env config, reconcile and serve health
- commit
- 251be535b9bc3bde78786ce0e67046d500c92f6e
- author
- brice · · 2026-10-10 06:17 UTC
- parent
- b156bd8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +441 -5
A cmd/ermite/clock.go +10 -0
| @@ -0,0 +1,10 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import "time" | |
| 6 | + | ||
| 7 | + | // systemClock is the ports.Clock of the running server. | |
| 8 | + | type systemClock struct{} | |
| 9 | + | ||
| 10 | + | func (systemClock) Now() time.Time { return time.Now() } | |
| @@ -0,0 +1,10 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import "time" | |||
| 6 | + | ||||
| 7 | + | // systemClock is the ports.Clock of the running server. | |||
| 8 | + | type systemClock struct{} | |||
| 9 | + | ||||
| 10 | + | func (systemClock) Now() time.Time { return time.Now() } | |||
A cmd/ermite/datadir.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // prepareDataDir creates the directories of the data root (0700) and | |
| 12 | + | // recreates run/ and run/git-home/, which hold nothing that survives a start. | |
| 13 | + | func prepareDataDir(root string) error { | |
| 14 | + | for _, d := range []string{"", "repos", filepath.Join("repos", ".staging"), "trash", "kv"} { | |
| 15 | + | if err := os.MkdirAll(filepath.Join(root, d), 0o700); err != nil { | |
| 16 | + | return fmt.Errorf("prepare data directory: %w", err) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | run := filepath.Join(root, "run") | |
| 20 | + | if err := os.RemoveAll(run); err != nil { | |
| 21 | + | return fmt.Errorf("reset %s: %w", run, err) | |
| 22 | + | } | |
| 23 | + | if err := os.MkdirAll(filepath.Join(run, "git-home"), 0o700); err != nil { | |
| 24 | + | return fmt.Errorf("prepare %s: %w", run, err) | |
| 25 | + | } | |
| 26 | + | return nil | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // prepareDataDir creates the directories of the data root (0700) and | |||
| 12 | + | // recreates run/ and run/git-home/, which hold nothing that survives a start. | |||
| 13 | + | func prepareDataDir(root string) error { | |||
| 14 | + | for _, d := range []string{"", "repos", filepath.Join("repos", ".staging"), "trash", "kv"} { | |||
| 15 | + | if err := os.MkdirAll(filepath.Join(root, d), 0o700); err != nil { | |||
| 16 | + | return fmt.Errorf("prepare data directory: %w", err) | |||
| 17 | + | } | |||
| 18 | + | } | |||
| 19 | + | run := filepath.Join(root, "run") | |||
| 20 | + | if err := os.RemoveAll(run); err != nil { | |||
| 21 | + | return fmt.Errorf("reset %s: %w", run, err) | |||
| 22 | + | } | |||
| 23 | + | if err := os.MkdirAll(filepath.Join(run, "git-home"), 0o700); err != nil { | |||
| 24 | + | return fmt.Errorf("prepare %s: %w", run, err) | |||
| 25 | + | } | |||
| 26 | + | return nil | |||
| 27 | + | } | |||
M cmd/ermite/main.go +3 -5
| @@ -10,12 +10,10 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func main() { | |
| 13 | - | if len(os.Args) == 2 && os.Args[1] == "version" { | |
| 14 | - | fmt.Println(version()) | |
| 15 | - | return | |
| 13 | + | if err := newRootCmd().Execute(); err != nil { | |
| 14 | + | fmt.Fprintln(os.Stderr, "ermite:", err) | |
| 15 | + | os.Exit(1) | |
| 16 | 16 | } | |
| 17 | - | fmt.Fprintln(os.Stderr, "usage: ermite version") | |
| 18 | - | os.Exit(2) | |
| 19 | 17 | } | |
| 20 | 18 | ||
| 21 | 19 | func version() string { | |
| @@ -10,12 +10,10 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func main() { | 12 | func main() { | ||
| 13 | - | if len(os.Args) == 2 && os.Args[1] == "version" { | 13 | + | if err := newRootCmd().Execute(); err != nil { |
| 14 | - | fmt.Println(version()) | 14 | + | fmt.Fprintln(os.Stderr, "ermite:", err) |
| 15 | - | return | 15 | + | os.Exit(1) |
| 16 | } | 16 | } | ||
| 17 | - | fmt.Fprintln(os.Stderr, "usage: ermite version") | |||
| 18 | - | os.Exit(2) | |||
| 19 | } | 17 | } | ||
| 20 | 18 | ||||
| 21 | func version() string { | 19 | func version() string { | ||
A cmd/ermite/main_test.go +268 -0
| @@ -0,0 +1,268 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "errors" | |
| 8 | + | "io" | |
| 9 | + | "net" | |
| 10 | + | "net/http" | |
| 11 | + | "os" | |
| 12 | + | "os/exec" | |
| 13 | + | "path/filepath" | |
| 14 | + | "strconv" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "syscall" | |
| 18 | + | "testing" | |
| 19 | + | "time" | |
| 20 | + | ||
| 21 | + | "github.com/stretchr/testify/assert" | |
| 22 | + | "github.com/stretchr/testify/require" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | const adminToken = "0123456789abcdef0123456789abcdef-secret" | |
| 26 | + | ||
| 27 | + | var ( | |
| 28 | + | buildOnce sync.Once | |
| 29 | + | binPath string | |
| 30 | + | buildErr error | |
| 31 | + | ) | |
| 32 | + | ||
| 33 | + | // binary builds the ermite binary once per test run. | |
| 34 | + | func binary(t *testing.T) string { | |
| 35 | + | t.Helper() | |
| 36 | + | buildOnce.Do(func() { | |
| 37 | + | dir, err := os.MkdirTemp("", "ermite-bin-") | |
| 38 | + | if err != nil { | |
| 39 | + | buildErr = err | |
| 40 | + | return | |
| 41 | + | } | |
| 42 | + | binPath = filepath.Join(dir, "ermite") | |
| 43 | + | out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput() | |
| 44 | + | if err != nil { | |
| 45 | + | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | |
| 46 | + | } | |
| 47 | + | }) | |
| 48 | + | require.NoError(t, buildErr) | |
| 49 | + | return binPath | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestMain(m *testing.M) { | |
| 53 | + | code := m.Run() | |
| 54 | + | if binPath != "" { | |
| 55 | + | _ = os.RemoveAll(filepath.Dir(binPath)) | |
| 56 | + | } | |
| 57 | + | os.Exit(code) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | type syncBuffer struct { | |
| 61 | + | mu sync.Mutex | |
| 62 | + | b bytes.Buffer | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |
| 66 | + | s.mu.Lock() | |
| 67 | + | defer s.mu.Unlock() | |
| 68 | + | return s.b.Write(p) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func (s *syncBuffer) String() string { | |
| 72 | + | s.mu.Lock() | |
| 73 | + | defer s.mu.Unlock() | |
| 74 | + | return s.b.String() | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func freeAddr(t *testing.T) string { | |
| 78 | + | t.Helper() | |
| 79 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | addr := ln.Addr().String() | |
| 82 | + | require.NoError(t, ln.Close()) | |
| 83 | + | return addr | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func baseEnv() []string { | |
| 87 | + | return []string{"PATH=" + os.Getenv("PATH")} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func TestVersion(t *testing.T) { | |
| 91 | + | out, err := exec.Command(binary(t), "version").Output() | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out)) | |
| 94 | + | assert.Equal(t, 1, strings.Count(string(out), "\n")) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestUnknownCommandExitsNonZero(t *testing.T) { | |
| 98 | + | err := exec.Command(binary(t), "nope").Run() | |
| 99 | + | var exit *exec.ExitError | |
| 100 | + | require.True(t, errors.As(err, &exit)) | |
| 101 | + | assert.Equal(t, 1, exit.ExitCode()) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestServeMissingEnvListsEveryVariable(t *testing.T) { | |
| 105 | + | cmd := exec.Command(binary(t), "serve") | |
| 106 | + | cmd.Env = baseEnv() | |
| 107 | + | var stderr bytes.Buffer | |
| 108 | + | cmd.Stderr = &stderr | |
| 109 | + | err := cmd.Run() | |
| 110 | + | ||
| 111 | + | var exit *exec.ExitError | |
| 112 | + | require.True(t, errors.As(err, &exit), "expected a failure, got %v", err) | |
| 113 | + | assert.Equal(t, 1, exit.ExitCode()) | |
| 114 | + | for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN"} { | |
| 115 | + | assert.Contains(t, stderr.String(), name) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestServeInvalidEnvNeverPrintsToken(t *testing.T) { | |
| 120 | + | cmd := exec.Command(binary(t), "serve") | |
| 121 | + | cmd.Env = append(baseEnv(), | |
| 122 | + | "ERMITE_DATA_DIR=relative", | |
| 123 | + | "ERMITE_PUBLIC_URL=ftp://x", | |
| 124 | + | "ERMITE_ADMIN_TOKEN=short-secret-value", | |
| 125 | + | "ERMITE_ADDR=bad", | |
| 126 | + | ) | |
| 127 | + | var out bytes.Buffer | |
| 128 | + | cmd.Stdout, cmd.Stderr = &out, &out | |
| 129 | + | err := cmd.Run() | |
| 130 | + | var exit *exec.ExitError | |
| 131 | + | require.True(t, errors.As(err, &exit)) | |
| 132 | + | assert.Equal(t, 1, exit.ExitCode()) | |
| 133 | + | for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN", "ERMITE_ADDR"} { | |
| 134 | + | assert.Contains(t, out.String(), name) | |
| 135 | + | } | |
| 136 | + | assert.NotContains(t, out.String(), "short-secret-value") | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestServeGitTooOldNamesVariable(t *testing.T) { | |
| 140 | + | script := filepath.Join(t.TempDir(), "git") | |
| 141 | + | require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho 'git version 2.44.0'\n"), 0o700)) | |
| 142 | + | data := t.TempDir() | |
| 143 | + | ||
| 144 | + | cmd := exec.Command(binary(t), "serve") | |
| 145 | + | cmd.Env = append(baseEnv(), | |
| 146 | + | "ERMITE_DATA_DIR="+data, | |
| 147 | + | "ERMITE_PUBLIC_URL=http://localhost", | |
| 148 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |
| 149 | + | "ERMITE_ADDR="+freeAddr(t), | |
| 150 | + | "ERMITE_GIT="+script, | |
| 151 | + | ) | |
| 152 | + | var out bytes.Buffer | |
| 153 | + | cmd.Stdout, cmd.Stderr = &out, &out | |
| 154 | + | err := cmd.Run() | |
| 155 | + | var exit *exec.ExitError | |
| 156 | + | require.True(t, errors.As(err, &exit)) | |
| 157 | + | assert.Equal(t, 1, exit.ExitCode()) | |
| 158 | + | assert.Contains(t, out.String(), "ERMITE_GIT") | |
| 159 | + | assert.NotContains(t, out.String(), adminToken) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func TestServeMissingGitNamesVariable(t *testing.T) { | |
| 163 | + | cmd := exec.Command(binary(t), "serve") | |
| 164 | + | cmd.Env = append(baseEnv(), | |
| 165 | + | "ERMITE_DATA_DIR="+t.TempDir(), | |
| 166 | + | "ERMITE_PUBLIC_URL=http://localhost", | |
| 167 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |
| 168 | + | "ERMITE_ADDR="+freeAddr(t), | |
| 169 | + | "ERMITE_GIT=/nonexistent/git", | |
| 170 | + | ) | |
| 171 | + | var out bytes.Buffer | |
| 172 | + | cmd.Stdout, cmd.Stderr = &out, &out | |
| 173 | + | err := cmd.Run() | |
| 174 | + | var exit *exec.ExitError | |
| 175 | + | require.True(t, errors.As(err, &exit)) | |
| 176 | + | assert.Equal(t, 1, exit.ExitCode()) | |
| 177 | + | assert.Contains(t, out.String(), "ERMITE_GIT") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestServeHealthAndSigterm(t *testing.T) { | |
| 181 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 182 | + | t.Skip("git not found") | |
| 183 | + | } | |
| 184 | + | data := t.TempDir() | |
| 185 | + | // State from a previous run: run/ is recreated, an orphan repository is | |
| 186 | + | // registered, a staging leftover is removed. | |
| 187 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "run", "stale"), 0o700)) | |
| 188 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging", "leftover"), 0o700)) | |
| 189 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", "orphan.git"), 0o700)) | |
| 190 | + | addr := freeAddr(t) | |
| 191 | + | ||
| 192 | + | cmd := exec.Command(binary(t), "serve") | |
| 193 | + | cmd.Env = append(baseEnv(), | |
| 194 | + | "ERMITE_ADDR="+addr, | |
| 195 | + | "ERMITE_DATA_DIR="+data, | |
| 196 | + | "ERMITE_PUBLIC_URL=http://"+addr, | |
| 197 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |
| 198 | + | "ERMITE_LOG_LEVEL=debug", | |
| 199 | + | "ERMITE_LOG_FORMAT=json", | |
| 200 | + | "GIT_DIR=/nonexistent", // must not leak into git | |
| 201 | + | ) | |
| 202 | + | var logs syncBuffer | |
| 203 | + | cmd.Stdout, cmd.Stderr = &logs, &logs | |
| 204 | + | require.NoError(t, cmd.Start()) | |
| 205 | + | exited := make(chan error, 1) | |
| 206 | + | go func() { exited <- cmd.Wait() }() | |
| 207 | + | t.Cleanup(func() { | |
| 208 | + | _ = cmd.Process.Kill() | |
| 209 | + | }) | |
| 210 | + | ||
| 211 | + | base := "http://" + addr | |
| 212 | + | var resp *http.Response | |
| 213 | + | deadline := time.Now().Add(15 * time.Second) | |
| 214 | + | for { | |
| 215 | + | var err error | |
| 216 | + | resp, err = http.Get(base + "/-/health") | |
| 217 | + | if err == nil { | |
| 218 | + | break | |
| 219 | + | } | |
| 220 | + | select { | |
| 221 | + | case werr := <-exited: | |
| 222 | + | t.Fatalf("server exited before answering: %v\n%s", werr, logs.String()) | |
| 223 | + | default: | |
| 224 | + | } | |
| 225 | + | if time.Now().After(deadline) { | |
| 226 | + | t.Fatalf("health never answered: %v\n%s", err, logs.String()) | |
| 227 | + | } | |
| 228 | + | time.Sleep(50 * time.Millisecond) | |
| 229 | + | } | |
| 230 | + | body, err := io.ReadAll(resp.Body) | |
| 231 | + | require.NoError(t, err) | |
| 232 | + | require.NoError(t, resp.Body.Close()) | |
| 233 | + | assert.Equal(t, 200, resp.StatusCode) | |
| 234 | + | assert.Equal(t, "ok\n", string(body)) | |
| 235 | + | assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options")) | |
| 236 | + | assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") | |
| 237 | + | ||
| 238 | + | resp404, err := http.Get(base + "/anything") | |
| 239 | + | require.NoError(t, err) | |
| 240 | + | _ = resp404.Body.Close() | |
| 241 | + | assert.Equal(t, 404, resp404.StatusCode) | |
| 242 | + | ||
| 243 | + | // Data directory layout and modes. | |
| 244 | + | for _, d := range []string{"repos", "repos/.staging", "trash", "kv", "run", "run/git-home"} { | |
| 245 | + | fi, err := os.Stat(filepath.Join(data, d)) | |
| 246 | + | require.NoError(t, err, d) | |
| 247 | + | assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm(), d) | |
| 248 | + | } | |
| 249 | + | assert.NoDirExists(t, filepath.Join(data, "run", "stale")) | |
| 250 | + | assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", "leftover")) | |
| 251 | + | assert.Contains(t, logs.String(), `"registered":1`) | |
| 252 | + | ||
| 253 | + | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |
| 254 | + | select { | |
| 255 | + | case err := <-exited: | |
| 256 | + | require.NoError(t, err, "exit status 0 expected\n%s", logs.String()) | |
| 257 | + | case <-time.After(15 * time.Second): | |
| 258 | + | t.Fatalf("server did not stop after SIGTERM\n%s", logs.String()) | |
| 259 | + | } | |
| 260 | + | out := logs.String() | |
| 261 | + | assert.Contains(t, out, "shutting down") | |
| 262 | + | assert.NotContains(t, out, adminToken) | |
| 263 | + | assert.NotContains(t, out, strconv.Quote(adminToken)) | |
| 264 | + | assert.False(t, strings.Contains(out, `"level":"error"`), "no error logged:\n%s", out) | |
| 265 | + | ||
| 266 | + | _, err = http.Get(base + "/-/health") | |
| 267 | + | assert.Error(t, err, "the port is closed") | |
| 268 | + | } | |
| @@ -0,0 +1,268 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "errors" | |||
| 8 | + | "io" | |||
| 9 | + | "net" | |||
| 10 | + | "net/http" | |||
| 11 | + | "os" | |||
| 12 | + | "os/exec" | |||
| 13 | + | "path/filepath" | |||
| 14 | + | "strconv" | |||
| 15 | + | "strings" | |||
| 16 | + | "sync" | |||
| 17 | + | "syscall" | |||
| 18 | + | "testing" | |||
| 19 | + | "time" | |||
| 20 | + | ||||
| 21 | + | "github.com/stretchr/testify/assert" | |||
| 22 | + | "github.com/stretchr/testify/require" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | const adminToken = "0123456789abcdef0123456789abcdef-secret" | |||
| 26 | + | ||||
| 27 | + | var ( | |||
| 28 | + | buildOnce sync.Once | |||
| 29 | + | binPath string | |||
| 30 | + | buildErr error | |||
| 31 | + | ) | |||
| 32 | + | ||||
| 33 | + | // binary builds the ermite binary once per test run. | |||
| 34 | + | func binary(t *testing.T) string { | |||
| 35 | + | t.Helper() | |||
| 36 | + | buildOnce.Do(func() { | |||
| 37 | + | dir, err := os.MkdirTemp("", "ermite-bin-") | |||
| 38 | + | if err != nil { | |||
| 39 | + | buildErr = err | |||
| 40 | + | return | |||
| 41 | + | } | |||
| 42 | + | binPath = filepath.Join(dir, "ermite") | |||
| 43 | + | out, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput() | |||
| 44 | + | if err != nil { | |||
| 45 | + | buildErr = errors.New("go build: " + err.Error() + "\n" + string(out)) | |||
| 46 | + | } | |||
| 47 | + | }) | |||
| 48 | + | require.NoError(t, buildErr) | |||
| 49 | + | return binPath | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestMain(m *testing.M) { | |||
| 53 | + | code := m.Run() | |||
| 54 | + | if binPath != "" { | |||
| 55 | + | _ = os.RemoveAll(filepath.Dir(binPath)) | |||
| 56 | + | } | |||
| 57 | + | os.Exit(code) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type syncBuffer struct { | |||
| 61 | + | mu sync.Mutex | |||
| 62 | + | b bytes.Buffer | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |||
| 66 | + | s.mu.Lock() | |||
| 67 | + | defer s.mu.Unlock() | |||
| 68 | + | return s.b.Write(p) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func (s *syncBuffer) String() string { | |||
| 72 | + | s.mu.Lock() | |||
| 73 | + | defer s.mu.Unlock() | |||
| 74 | + | return s.b.String() | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func freeAddr(t *testing.T) string { | |||
| 78 | + | t.Helper() | |||
| 79 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | addr := ln.Addr().String() | |||
| 82 | + | require.NoError(t, ln.Close()) | |||
| 83 | + | return addr | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func baseEnv() []string { | |||
| 87 | + | return []string{"PATH=" + os.Getenv("PATH")} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestVersion(t *testing.T) { | |||
| 91 | + | out, err := exec.Command(binary(t), "version").Output() | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | assert.True(t, strings.HasPrefix(string(out), "ermite "), string(out)) | |||
| 94 | + | assert.Equal(t, 1, strings.Count(string(out), "\n")) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestUnknownCommandExitsNonZero(t *testing.T) { | |||
| 98 | + | err := exec.Command(binary(t), "nope").Run() | |||
| 99 | + | var exit *exec.ExitError | |||
| 100 | + | require.True(t, errors.As(err, &exit)) | |||
| 101 | + | assert.Equal(t, 1, exit.ExitCode()) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestServeMissingEnvListsEveryVariable(t *testing.T) { | |||
| 105 | + | cmd := exec.Command(binary(t), "serve") | |||
| 106 | + | cmd.Env = baseEnv() | |||
| 107 | + | var stderr bytes.Buffer | |||
| 108 | + | cmd.Stderr = &stderr | |||
| 109 | + | err := cmd.Run() | |||
| 110 | + | ||||
| 111 | + | var exit *exec.ExitError | |||
| 112 | + | require.True(t, errors.As(err, &exit), "expected a failure, got %v", err) | |||
| 113 | + | assert.Equal(t, 1, exit.ExitCode()) | |||
| 114 | + | for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN"} { | |||
| 115 | + | assert.Contains(t, stderr.String(), name) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestServeInvalidEnvNeverPrintsToken(t *testing.T) { | |||
| 120 | + | cmd := exec.Command(binary(t), "serve") | |||
| 121 | + | cmd.Env = append(baseEnv(), | |||
| 122 | + | "ERMITE_DATA_DIR=relative", | |||
| 123 | + | "ERMITE_PUBLIC_URL=ftp://x", | |||
| 124 | + | "ERMITE_ADMIN_TOKEN=short-secret-value", | |||
| 125 | + | "ERMITE_ADDR=bad", | |||
| 126 | + | ) | |||
| 127 | + | var out bytes.Buffer | |||
| 128 | + | cmd.Stdout, cmd.Stderr = &out, &out | |||
| 129 | + | err := cmd.Run() | |||
| 130 | + | var exit *exec.ExitError | |||
| 131 | + | require.True(t, errors.As(err, &exit)) | |||
| 132 | + | assert.Equal(t, 1, exit.ExitCode()) | |||
| 133 | + | for _, name := range []string{"ERMITE_DATA_DIR", "ERMITE_PUBLIC_URL", "ERMITE_ADMIN_TOKEN", "ERMITE_ADDR"} { | |||
| 134 | + | assert.Contains(t, out.String(), name) | |||
| 135 | + | } | |||
| 136 | + | assert.NotContains(t, out.String(), "short-secret-value") | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestServeGitTooOldNamesVariable(t *testing.T) { | |||
| 140 | + | script := filepath.Join(t.TempDir(), "git") | |||
| 141 | + | require.NoError(t, os.WriteFile(script, []byte("#!/bin/sh\necho 'git version 2.44.0'\n"), 0o700)) | |||
| 142 | + | data := t.TempDir() | |||
| 143 | + | ||||
| 144 | + | cmd := exec.Command(binary(t), "serve") | |||
| 145 | + | cmd.Env = append(baseEnv(), | |||
| 146 | + | "ERMITE_DATA_DIR="+data, | |||
| 147 | + | "ERMITE_PUBLIC_URL=http://localhost", | |||
| 148 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |||
| 149 | + | "ERMITE_ADDR="+freeAddr(t), | |||
| 150 | + | "ERMITE_GIT="+script, | |||
| 151 | + | ) | |||
| 152 | + | var out bytes.Buffer | |||
| 153 | + | cmd.Stdout, cmd.Stderr = &out, &out | |||
| 154 | + | err := cmd.Run() | |||
| 155 | + | var exit *exec.ExitError | |||
| 156 | + | require.True(t, errors.As(err, &exit)) | |||
| 157 | + | assert.Equal(t, 1, exit.ExitCode()) | |||
| 158 | + | assert.Contains(t, out.String(), "ERMITE_GIT") | |||
| 159 | + | assert.NotContains(t, out.String(), adminToken) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | func TestServeMissingGitNamesVariable(t *testing.T) { | |||
| 163 | + | cmd := exec.Command(binary(t), "serve") | |||
| 164 | + | cmd.Env = append(baseEnv(), | |||
| 165 | + | "ERMITE_DATA_DIR="+t.TempDir(), | |||
| 166 | + | "ERMITE_PUBLIC_URL=http://localhost", | |||
| 167 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |||
| 168 | + | "ERMITE_ADDR="+freeAddr(t), | |||
| 169 | + | "ERMITE_GIT=/nonexistent/git", | |||
| 170 | + | ) | |||
| 171 | + | var out bytes.Buffer | |||
| 172 | + | cmd.Stdout, cmd.Stderr = &out, &out | |||
| 173 | + | err := cmd.Run() | |||
| 174 | + | var exit *exec.ExitError | |||
| 175 | + | require.True(t, errors.As(err, &exit)) | |||
| 176 | + | assert.Equal(t, 1, exit.ExitCode()) | |||
| 177 | + | assert.Contains(t, out.String(), "ERMITE_GIT") | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func TestServeHealthAndSigterm(t *testing.T) { | |||
| 181 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 182 | + | t.Skip("git not found") | |||
| 183 | + | } | |||
| 184 | + | data := t.TempDir() | |||
| 185 | + | // State from a previous run: run/ is recreated, an orphan repository is | |||
| 186 | + | // registered, a staging leftover is removed. | |||
| 187 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "run", "stale"), 0o700)) | |||
| 188 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", ".staging", "leftover"), 0o700)) | |||
| 189 | + | require.NoError(t, os.MkdirAll(filepath.Join(data, "repos", "orphan.git"), 0o700)) | |||
| 190 | + | addr := freeAddr(t) | |||
| 191 | + | ||||
| 192 | + | cmd := exec.Command(binary(t), "serve") | |||
| 193 | + | cmd.Env = append(baseEnv(), | |||
| 194 | + | "ERMITE_ADDR="+addr, | |||
| 195 | + | "ERMITE_DATA_DIR="+data, | |||
| 196 | + | "ERMITE_PUBLIC_URL=http://"+addr, | |||
| 197 | + | "ERMITE_ADMIN_TOKEN="+adminToken, | |||
| 198 | + | "ERMITE_LOG_LEVEL=debug", | |||
| 199 | + | "ERMITE_LOG_FORMAT=json", | |||
| 200 | + | "GIT_DIR=/nonexistent", // must not leak into git | |||
| 201 | + | ) | |||
| 202 | + | var logs syncBuffer | |||
| 203 | + | cmd.Stdout, cmd.Stderr = &logs, &logs | |||
| 204 | + | require.NoError(t, cmd.Start()) | |||
| 205 | + | exited := make(chan error, 1) | |||
| 206 | + | go func() { exited <- cmd.Wait() }() | |||
| 207 | + | t.Cleanup(func() { | |||
| 208 | + | _ = cmd.Process.Kill() | |||
| 209 | + | }) | |||
| 210 | + | ||||
| 211 | + | base := "http://" + addr | |||
| 212 | + | var resp *http.Response | |||
| 213 | + | deadline := time.Now().Add(15 * time.Second) | |||
| 214 | + | for { | |||
| 215 | + | var err error | |||
| 216 | + | resp, err = http.Get(base + "/-/health") | |||
| 217 | + | if err == nil { | |||
| 218 | + | break | |||
| 219 | + | } | |||
| 220 | + | select { | |||
| 221 | + | case werr := <-exited: | |||
| 222 | + | t.Fatalf("server exited before answering: %v\n%s", werr, logs.String()) | |||
| 223 | + | default: | |||
| 224 | + | } | |||
| 225 | + | if time.Now().After(deadline) { | |||
| 226 | + | t.Fatalf("health never answered: %v\n%s", err, logs.String()) | |||
| 227 | + | } | |||
| 228 | + | time.Sleep(50 * time.Millisecond) | |||
| 229 | + | } | |||
| 230 | + | body, err := io.ReadAll(resp.Body) | |||
| 231 | + | require.NoError(t, err) | |||
| 232 | + | require.NoError(t, resp.Body.Close()) | |||
| 233 | + | assert.Equal(t, 200, resp.StatusCode) | |||
| 234 | + | assert.Equal(t, "ok\n", string(body)) | |||
| 235 | + | assert.Equal(t, "DENY", resp.Header.Get("X-Frame-Options")) | |||
| 236 | + | assert.Contains(t, resp.Header.Get("Content-Security-Policy"), "frame-ancestors 'none'") | |||
| 237 | + | ||||
| 238 | + | resp404, err := http.Get(base + "/anything") | |||
| 239 | + | require.NoError(t, err) | |||
| 240 | + | _ = resp404.Body.Close() | |||
| 241 | + | assert.Equal(t, 404, resp404.StatusCode) | |||
| 242 | + | ||||
| 243 | + | // Data directory layout and modes. | |||
| 244 | + | for _, d := range []string{"repos", "repos/.staging", "trash", "kv", "run", "run/git-home"} { | |||
| 245 | + | fi, err := os.Stat(filepath.Join(data, d)) | |||
| 246 | + | require.NoError(t, err, d) | |||
| 247 | + | assert.Equal(t, os.FileMode(0o700), fi.Mode().Perm(), d) | |||
| 248 | + | } | |||
| 249 | + | assert.NoDirExists(t, filepath.Join(data, "run", "stale")) | |||
| 250 | + | assert.NoDirExists(t, filepath.Join(data, "repos", ".staging", "leftover")) | |||
| 251 | + | assert.Contains(t, logs.String(), `"registered":1`) | |||
| 252 | + | ||||
| 253 | + | require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) | |||
| 254 | + | select { | |||
| 255 | + | case err := <-exited: | |||
| 256 | + | require.NoError(t, err, "exit status 0 expected\n%s", logs.String()) | |||
| 257 | + | case <-time.After(15 * time.Second): | |||
| 258 | + | t.Fatalf("server did not stop after SIGTERM\n%s", logs.String()) | |||
| 259 | + | } | |||
| 260 | + | out := logs.String() | |||
| 261 | + | assert.Contains(t, out, "shutting down") | |||
| 262 | + | assert.NotContains(t, out, adminToken) | |||
| 263 | + | assert.NotContains(t, out, strconv.Quote(adminToken)) | |||
| 264 | + | assert.False(t, strings.Contains(out, `"level":"error"`), "no error logged:\n%s", out) | |||
| 265 | + | ||||
| 266 | + | _, err = http.Get(base + "/-/health") | |||
| 267 | + | assert.Error(t, err, "the port is closed") | |||
| 268 | + | } | |||
A cmd/ermite/root.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 8 | + | "github.com/spf13/cobra" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func newRootCmd() *cobra.Command { | |
| 12 | + | root := &cobra.Command{ | |
| 13 | + | Use: "ermite", | |
| 14 | + | Short: "A single-user Git forge", | |
| 15 | + | SilenceUsage: true, | |
| 16 | + | SilenceErrors: true, // main prints the error once | |
| 17 | + | CompletionOptions: cobra.CompletionOptions{ | |
| 18 | + | DisableDefaultCmd: true, | |
| 19 | + | }, | |
| 20 | + | } | |
| 21 | + | root.AddCommand(newServeCmd(), newVersionCmd()) | |
| 22 | + | return root | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func newVersionCmd() *cobra.Command { | |
| 26 | + | return &cobra.Command{ | |
| 27 | + | Use: "version", | |
| 28 | + | Short: "Print the version", | |
| 29 | + | Args: cobra.NoArgs, | |
| 30 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 31 | + | _, err := fmt.Fprintln(cmd.OutOrStdout(), version()) | |
| 32 | + | return err | |
| 33 | + | }, | |
| 34 | + | } | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 8 | + | "github.com/spf13/cobra" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func newRootCmd() *cobra.Command { | |||
| 12 | + | root := &cobra.Command{ | |||
| 13 | + | Use: "ermite", | |||
| 14 | + | Short: "A single-user Git forge", | |||
| 15 | + | SilenceUsage: true, | |||
| 16 | + | SilenceErrors: true, // main prints the error once | |||
| 17 | + | CompletionOptions: cobra.CompletionOptions{ | |||
| 18 | + | DisableDefaultCmd: true, | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | root.AddCommand(newServeCmd(), newVersionCmd()) | |||
| 22 | + | return root | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func newVersionCmd() *cobra.Command { | |||
| 26 | + | return &cobra.Command{ | |||
| 27 | + | Use: "version", | |||
| 28 | + | Short: "Print the version", | |||
| 29 | + | Args: cobra.NoArgs, | |||
| 30 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 31 | + | _, err := fmt.Fprintln(cmd.OutOrStdout(), version()) | |||
| 32 | + | return err | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | } | |||
A cmd/ermite/serve.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "os/signal" | |
| 10 | + | "path/filepath" | |
| 11 | + | "syscall" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | "github.com/spf13/cobra" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |
| 17 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 18 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 19 | + | "bnema.dev/ermite/internal/app" | |
| 20 | + | "bnema.dev/ermite/internal/config" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | func newServeCmd() *cobra.Command { | |
| 24 | + | return &cobra.Command{ | |
| 25 | + | Use: "serve", | |
| 26 | + | Short: "Run the server", | |
| 27 | + | Long: "Run the server. It is configured by the environment only:\n" + | |
| 28 | + | config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" + | |
| 29 | + | config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " + | |
| 30 | + | config.EnvLogLevel + ", " + config.EnvLogFormat + ".", | |
| 31 | + | Args: cobra.NoArgs, | |
| 32 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 33 | + | return serve(cmd.Context()) | |
| 34 | + | }, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | |
| 39 | + | func serve(ctx context.Context) error { | |
| 40 | + | cfg, err := config.Load(os.LookupEnv) | |
| 41 | + | if err != nil { | |
| 42 | + | return err | |
| 43 | + | } | |
| 44 | + | // The logger is built from the validated settings, which carry the | |
| 45 | + | // defaults of ERMITE_LOG_LEVEL and ERMITE_LOG_FORMAT. | |
| 46 | + | log := zerowrap.New(zerowrap.Config{Level: cfg.LogLevel, Format: cfg.LogFormat}) | |
| 47 | + | ctx = zerowrap.WithCtx(ctx, log) | |
| 48 | + | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |
| 49 | + | defer stop() | |
| 50 | + | ||
| 51 | + | log.Info().Str("version", version()).Str("addr", cfg.Addr).Str("data_dir", cfg.DataDir).Msg("starting") | |
| 52 | + | ||
| 53 | + | if err := prepareDataDir(cfg.DataDir); err != nil { | |
| 54 | + | return err | |
| 55 | + | } | |
| 56 | + | store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv")) | |
| 57 | + | if err != nil { | |
| 58 | + | return err | |
| 59 | + | } | |
| 60 | + | // The server has stopped by the time this runs; close even when ctx ended. | |
| 61 | + | defer func() { | |
| 62 | + | if cerr := store.Close(context.WithoutCancel(ctx)); cerr != nil { | |
| 63 | + | log.Error().Err(cerr).Msg("close metadata store") | |
| 64 | + | } | |
| 65 | + | }() | |
| 66 | + | ||
| 67 | + | path := os.Getenv("PATH") | |
| 68 | + | if path == "" { | |
| 69 | + | path = "/usr/local/bin:/usr/bin:/bin" | |
| 70 | + | } | |
| 71 | + | git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path}) | |
| 72 | + | gitVersion, err := git.CheckVersion(ctx) | |
| 73 | + | if err != nil { | |
| 74 | + | return fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err) | |
| 75 | + | } | |
| 76 | + | log.Info().Str("git", gitVersion.String()).Msg("git checked") | |
| 77 | + | ||
| 78 | + | repos := app.NewRepos(store, git, systemClock{}) | |
| 79 | + | report, err := repos.Reconcile(ctx) | |
| 80 | + | if err != nil { | |
| 81 | + | return fmt.Errorf("reconcile repositories: %w", err) | |
| 82 | + | } | |
| 83 | + | log.Info(). | |
| 84 | + | Int("registered", len(report.Registered)). | |
| 85 | + | Int("missing_dirs", len(report.MissingDirs)). | |
| 86 | + | Int("invalid_dirs", len(report.InvalidDirs)). | |
| 87 | + | Msg("repositories reconciled") | |
| 88 | + | ||
| 89 | + | srv := httpserver.New(httpserver.Config{ | |
| 90 | + | Addr: cfg.Addr, | |
| 91 | + | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |
| 92 | + | }) | |
| 93 | + | if err := srv.Run(ctx); err != nil { | |
| 94 | + | return err | |
| 95 | + | } | |
| 96 | + | log.Info().Msg("stopped") | |
| 97 | + | return nil | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "os/signal" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "syscall" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | "github.com/spf13/cobra" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/adapters/gitcli" | |||
| 17 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |||
| 18 | + | "bnema.dev/ermite/internal/adapters/kvstore" | |||
| 19 | + | "bnema.dev/ermite/internal/app" | |||
| 20 | + | "bnema.dev/ermite/internal/config" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | func newServeCmd() *cobra.Command { | |||
| 24 | + | return &cobra.Command{ | |||
| 25 | + | Use: "serve", | |||
| 26 | + | Short: "Run the server", | |||
| 27 | + | Long: "Run the server. It is configured by the environment only:\n" + | |||
| 28 | + | config.EnvAddr + ", " + config.EnvDataDir + ", " + config.EnvPublicURL + ", " + config.EnvAdminToken + ",\n" + | |||
| 29 | + | config.EnvTrustedProxies + ", " + config.EnvGit + ", " + config.EnvSourceURL + ", " + | |||
| 30 | + | config.EnvLogLevel + ", " + config.EnvLogFormat + ".", | |||
| 31 | + | Args: cobra.NoArgs, | |||
| 32 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |||
| 33 | + | return serve(cmd.Context()) | |||
| 34 | + | }, | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // serve wires the adapters and runs the server until SIGINT or SIGTERM. | |||
| 39 | + | func serve(ctx context.Context) error { | |||
| 40 | + | cfg, err := config.Load(os.LookupEnv) | |||
| 41 | + | if err != nil { | |||
| 42 | + | return err | |||
| 43 | + | } | |||
| 44 | + | // The logger is built from the validated settings, which carry the | |||
| 45 | + | // defaults of ERMITE_LOG_LEVEL and ERMITE_LOG_FORMAT. | |||
| 46 | + | log := zerowrap.New(zerowrap.Config{Level: cfg.LogLevel, Format: cfg.LogFormat}) | |||
| 47 | + | ctx = zerowrap.WithCtx(ctx, log) | |||
| 48 | + | ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) | |||
| 49 | + | defer stop() | |||
| 50 | + | ||||
| 51 | + | log.Info().Str("version", version()).Str("addr", cfg.Addr).Str("data_dir", cfg.DataDir).Msg("starting") | |||
| 52 | + | ||||
| 53 | + | if err := prepareDataDir(cfg.DataDir); err != nil { | |||
| 54 | + | return err | |||
| 55 | + | } | |||
| 56 | + | store, err := kvstore.Open(ctx, filepath.Join(cfg.DataDir, "kv")) | |||
| 57 | + | if err != nil { | |||
| 58 | + | return err | |||
| 59 | + | } | |||
| 60 | + | // The server has stopped by the time this runs; close even when ctx ended. | |||
| 61 | + | defer func() { | |||
| 62 | + | if cerr := store.Close(context.WithoutCancel(ctx)); cerr != nil { | |||
| 63 | + | log.Error().Err(cerr).Msg("close metadata store") | |||
| 64 | + | } | |||
| 65 | + | }() | |||
| 66 | + | ||||
| 67 | + | path := os.Getenv("PATH") | |||
| 68 | + | if path == "" { | |||
| 69 | + | path = "/usr/local/bin:/usr/bin:/bin" | |||
| 70 | + | } | |||
| 71 | + | git := gitcli.New(gitcli.Config{Bin: cfg.Git, DataDir: cfg.DataDir, Path: path}) | |||
| 72 | + | gitVersion, err := git.CheckVersion(ctx) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return fmt.Errorf("%s=%q: %w", config.EnvGit, cfg.Git, err) | |||
| 75 | + | } | |||
| 76 | + | log.Info().Str("git", gitVersion.String()).Msg("git checked") | |||
| 77 | + | ||||
| 78 | + | repos := app.NewRepos(store, git, systemClock{}) | |||
| 79 | + | report, err := repos.Reconcile(ctx) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return fmt.Errorf("reconcile repositories: %w", err) | |||
| 82 | + | } | |||
| 83 | + | log.Info(). | |||
| 84 | + | Int("registered", len(report.Registered)). | |||
| 85 | + | Int("missing_dirs", len(report.MissingDirs)). | |||
| 86 | + | Int("invalid_dirs", len(report.InvalidDirs)). | |||
| 87 | + | Msg("repositories reconciled") | |||
| 88 | + | ||||
| 89 | + | srv := httpserver.New(httpserver.Config{ | |||
| 90 | + | Addr: cfg.Addr, | |||
| 91 | + | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |||
| 92 | + | }) | |||
| 93 | + | if err := srv.Run(ctx); err != nil { | |||
| 94 | + | return err | |||
| 95 | + | } | |||
| 96 | + | log.Info().Msg("stopped") | |||
| 97 | + | return nil | |||
| 98 | + | } | |||