detached 251be53

feat(serve): load env config, reconcile and serve health

commit
251be535b9bc3bde78786ce0e67046d500c92f6e
author
brice · · 2026-10-10 06:17 UTC
parent
b156bd8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
1010 )
1111
1212 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)
1616 }
17- fmt.Fprintln(os.Stderr, "usage: ermite version")
18- os.Exit(2)
1917 }
2018
2119 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- return15+ 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close