detached 1427755

fix(mirror): sanitize push output, harden SSH options, add mirror events to watch and stable --json output

commit
14277557d3ddad56818fab1776a294216cfe3081
author
brice · · 2026-10-20 07:06 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
5805840
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
24 files · +383 -91
M docs/ci-events.md +2 -2
@@ -67,7 +67,7 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …
6767
6868 Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident.
6969
70-- `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`), `reason`.
70+- `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`). In `ermite watch --json` they are `mirror_failed` (with `state`, `failure_class`) and `mirror_recovered`.
7171 - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`.
7272
73-The payload carries neither the remote URL nor the error detail; `ermite mirror status` shows the reason.
73+The payload carries neither the remote URL, the reason nor the error detail; `ermite mirror status` shows the reason to repository admins.
@@ -67,7 +67,7 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …
67 67
68 Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident.68 Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident.
69 69
70-- `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`), `reason`.70+- `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`). In `ermite watch --json` they are `mirror_failed` (with `state`, `failure_class`) and `mirror_recovered`.
71 - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`.71 - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`.
72 72
73-The payload carries neither the remote URL nor the error detail; `ermite mirror status` shows the reason.73+The payload carries neither the remote URL, the reason nor the error detail; `ermite mirror status` shows the reason to repository admins.
M docs/cli.md +2 -1
@@ -78,7 +78,8 @@ ermite environment create|list|delete … --repo <repo>
7878 ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only)
7979 ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only
8080 ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo>
81-ermite mirror status [repo] | sync <repo>|--all [--force]
81+ermite mirror status [repo] | sync <repo>|--all [--force] # sync --all: one row per repository, exit 1 if any failed
82+
8283 ermite web login # one-time sign-in link (60 s)
8384 ermite web logout --all # sign every browser out
8485 ermite web passkey list | remove <id>
@@ -78,7 +78,8 @@ ermite environment create|list|delete … --repo <repo>
78 ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only)78 ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only)
79 ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only79 ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only
80 ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo>80 ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo>
81-ermite mirror status [repo] | sync <repo>|--all [--force]81+ermite mirror status [repo] | sync <repo>|--all [--force] # sync --all: one row per repository, exit 1 if any failed
82+
82 ermite web login # one-time sign-in link (60 s)83 ermite web login # one-time sign-in link (60 s)
83 ermite web logout --all # sign every browser out84 ermite web logout --all # sign every browser out
84 ermite web passkey list | remove <id>85 ermite web passkey list | remove <id>
M docs/mirror.md +13 -5
@@ -37,12 +37,13 @@ URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. T
3737
3838 - The default branch and all tags; every branch with `--all-branches`.
3939 - Never a force push, never a deletion: a branch or tag deleted here stays on the remote.
40-- If the remote has commits ermite lacks (diverged), the mirror is **paused**. To fix it, fast-forward ermite with the remote's commits (push them here), then run `ermite mirror sync <repo> --force`.
40+- The push is atomic: if the remote refuses **any** ref, nothing is updated and the mirror is **paused** as diverged. Besides a branch the remote has moved ahead, this happens when a tag was moved or rewritten here (an existing remote tag is never overwritten), and for any other ref the remote rejects.
41+- To fix a diverged branch, fast-forward ermite with the remote's commits (push them here). To fix a tag, restore it here to the commit the remote has, or delete that tag on the remote yourself (ermite never deletes anything there). Then run `ermite mirror sync <repo> --force`.
4142
4243 ## When it syncs
4344
4445 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.
45-- `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights.
46+- `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed.
4647 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
4748 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
4849
@@ -62,13 +63,20 @@ A paused mirror is not retried by the schedule. Changing the mirror configuratio
6263 ## See what happens
6364
6465 - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures.
65-- A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, to people with admin rights.
66+- A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, **only to people with admin rights on the repository**: it names the remote and the reason, which other pushers do not see. The same holds for the URL and the reason in `mirror status` and the web badge.
6667 - The repository page shows a badge to owners and admins.
6768 - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events).
6869
6970 ## Security
7071
7172 - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user.
72-- Host keys are trusted on first use, kept in `<data dir>/mirror_known_hosts`. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`.
73-- `ermite mirror key rotate` (owner only) makes a new key at once. Every mirror fails until the new key is added to the remotes.
73+- Host keys are trusted on first use (TOFU), kept in `<data dir>/mirror_known_hosts`. The first connection to a host accepts whatever key it presents, so to be sure of it, pre-seed the file before configuring the mirror and compare with what the host publishes:
74+
75+ ```sh
76+ ssh-keyscan -t ed25519,rsa,ecdsa github.com | tee -a <data dir>/mirror_known_hosts
77+ ssh-keygen -lf <data dir>/mirror_known_hosts # compare with https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
78+ ```
79+
80+ Remove any line whose fingerprint does not match. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`.
81+- `ermite mirror key rotate` (owner only) makes a new key at once and prints the fingerprint of the replaced one on stderr. Every mirror fails until the new key is added to the remotes.
7482 - The scheduler runs in the server process: run a single ermite instance per data directory.
@@ -37,12 +37,13 @@ URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. T
37 37
38 - The default branch and all tags; every branch with `--all-branches`.38 - The default branch and all tags; every branch with `--all-branches`.
39 - Never a force push, never a deletion: a branch or tag deleted here stays on the remote.39 - Never a force push, never a deletion: a branch or tag deleted here stays on the remote.
40-- If the remote has commits ermite lacks (diverged), the mirror is **paused**. To fix it, fast-forward ermite with the remote's commits (push them here), then run `ermite mirror sync <repo> --force`.40+- The push is atomic: if the remote refuses **any** ref, nothing is updated and the mirror is **paused** as diverged. Besides a branch the remote has moved ahead, this happens when a tag was moved or rewritten here (an existing remote tag is never overwritten), and for any other ref the remote rejects.
41+- To fix a diverged branch, fast-forward ermite with the remote's commits (push them here). To fix a tag, restore it here to the commit the remote has, or delete that tag on the remote yourself (ermite never deletes anything there). Then run `ermite mirror sync <repo> --force`.
41 42
42 ## When it syncs43 ## When it syncs
43 44
44 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.45 - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing.
45-- `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights.46+- `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed.
46 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.47 - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
47 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.48 - Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
48 49
@@ -62,13 +63,20 @@ A paused mirror is not retried by the schedule. Changing the mirror configuratio
62 ## See what happens63 ## See what happens
63 64
64 - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures.65 - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures.
65-- A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, to people with admin rights.66+- A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, **only to people with admin rights on the repository**: it names the remote and the reason, which other pushers do not see. The same holds for the URL and the reason in `mirror status` and the web badge.
66 - The repository page shows a badge to owners and admins.67 - The repository page shows a badge to owners and admins.
67 - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events).68 - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events).
68 69
69 ## Security70 ## Security
70 71
71 - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user.72 - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user.
72-- Host keys are trusted on first use, kept in `<data dir>/mirror_known_hosts`. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`.73+- Host keys are trusted on first use (TOFU), kept in `<data dir>/mirror_known_hosts`. The first connection to a host accepts whatever key it presents, so to be sure of it, pre-seed the file before configuring the mirror and compare with what the host publishes:
73-- `ermite mirror key rotate` (owner only) makes a new key at once. Every mirror fails until the new key is added to the remotes.74+
75+ ```sh
76+ ssh-keyscan -t ed25519,rsa,ecdsa github.com | tee -a <data dir>/mirror_known_hosts
77+ ssh-keygen -lf <data dir>/mirror_known_hosts # compare with https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
78+ ```
79+
80+ Remove any line whose fingerprint does not match. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`.
81+- `ermite mirror key rotate` (owner only) makes a new key at once and prints the fingerprint of the replaced one on stderr. Every mirror fails until the new key is added to the remotes.
74 - The scheduler runs in the server process: run a single ermite instance per data directory.82 - The scheduler runs in the server process: run a single ermite instance per data directory.
M internal/adapters/githttp/hooks.go +3 -3
@@ -64,14 +64,14 @@ func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
6464 }
6565
6666 // mirrorWarning bounds even a source that does not honor context cancellation.
67-func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string {
67+func (s *HookServer) mirrorWarning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
6868 if s.mirrors == nil {
6969 return ""
7070 }
7171 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
7272 defer cancel()
7373 result := make(chan string, 1)
74- go func() { result <- s.mirrors.Warning(ctx, repo) }()
74+ go func() { result <- s.mirrors.Warning(ctx, p, repo) }()
7575 select {
7676 case warning := <-result:
7777 if ctx.Err() != nil || warning == "" {
@@ -256,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
256256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
257257 return
258258 }
259- s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)})
259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.principal, entry.repo)})
260260 }
261261 }
262262
@@ -64,14 +64,14 @@ func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
64 }64 }
65 65
66 // mirrorWarning bounds even a source that does not honor context cancellation.66 // mirrorWarning bounds even a source that does not honor context cancellation.
67-func (s *HookServer) mirrorWarning(ctx context.Context, repo domain.RepoName) string {67+func (s *HookServer) mirrorWarning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
68 if s.mirrors == nil {68 if s.mirrors == nil {
69 return ""69 return ""
70 }70 }
71 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)71 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
72 defer cancel()72 defer cancel()
73 result := make(chan string, 1)73 result := make(chan string, 1)
74- go func() { result <- s.mirrors.Warning(ctx, repo) }()74+ go func() { result <- s.mirrors.Warning(ctx, p, repo) }()
75 select {75 select {
76 case warning := <-result:76 case warning := <-result:
77 if ctx.Err() != nil || warning == "" {77 if ctx.Err() != nil || warning == "" {
@@ -256,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
257 return257 return
258 }258 }
259- s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.repo)})259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.principal, entry.repo)})
260 }260 }
261 }261 }
262 262
M internal/adapters/githttp/hooks_test.go +2 -2
@@ -420,7 +420,7 @@ func TestMirrorPostReceiveWarning(t *testing.T) {
420420 hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421421 srv, sock := startHooks(t, hooks)
422422 w := mocks.NewMirrorWarner(t)
423- w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning)
423+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).Return(warning)
424424 srv.WithMirrorWarner(w)
425425 token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426426 defer release()
@@ -442,7 +442,7 @@ func TestMirrorPostReceiveTimeout(t *testing.T) {
442442 w := mocks.NewMirrorWarner(t)
443443 release := make(chan struct{})
444444 defer close(release)
445- w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string {
445+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName) string {
446446 deadline, ok := ctx.Deadline()
447447 assert.True(t, ok)
448448 assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
@@ -420,7 +420,7 @@ func TestMirrorPostReceiveWarning(t *testing.T) {
420 hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)420 hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421 srv, sock := startHooks(t, hooks)421 srv, sock := startHooks(t, hooks)
422 w := mocks.NewMirrorWarner(t)422 w := mocks.NewMirrorWarner(t)
423- w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).Return(warning)423+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).Return(warning)
424 srv.WithMirrorWarner(w)424 srv.WithMirrorWarner(w)
425 token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())425 token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426 defer release()426 defer release()
@@ -442,7 +442,7 @@ func TestMirrorPostReceiveTimeout(t *testing.T) {
442 w := mocks.NewMirrorWarner(t)442 w := mocks.NewMirrorWarner(t)
443 release := make(chan struct{})443 release := make(chan struct{})
444 defer close(release)444 defer close(release)
445- w.EXPECT().Warning(mock.Anything, repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.RepoName) string {445+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName) string {
446 deadline, ok := ctx.Deadline()446 deadline, ok := ctx.Deadline()
447 assert.True(t, ok)447 assert.True(t, ok)
448 assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)448 assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
M internal/adapters/gitpush/gitpush.go +26 -5
@@ -14,6 +14,7 @@ import (
1414 "strings"
1515 "time"
1616 "unicode"
17+ "unicode/utf8"
1718
1819 "bnema.dev/ermite/internal/domain"
1920 "bnema.dev/ermite/internal/ports"
@@ -121,7 +122,7 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
121122 // -F /dev/null prevents ~/.ssh/config and system config from adding credentials
122123 // or weakening the required options. Required options precede caller extras.
123124 command := "ssh -F /dev/null -i " + quote(keyPath)
124- for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts}, p.cfg.SSHOptions...) {
125+ for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts, "ConnectTimeout=30", "ServerAliveInterval=15", "ServerAliveCountMax=3"}, p.cfg.SSHOptions...) {
125126 command += " -o " + quote(opt)
126127 }
127128 cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)
@@ -169,7 +170,9 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
169170 return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}
170171 }
171172 if runErr != nil || rejected {
172- class := domain.ClassifyMirrorOutput(stderr.buf.String()+"\n"+stdout.buf.String(), runErr, false)
173+ // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting
174+ // message must not pick the failure class. They stay in the detail.
175+ class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false)
173176 if rejected && class == domain.MirrorClassNone {
174177 class = domain.MirrorClassDiverged
175178 }
@@ -186,6 +189,18 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
186189 return result, nil
187190 }
188191
192+// withoutRemoteLines drops the lines the remote sent through git ("remote: ...").
193+func withoutRemoteLines(s string) string {
194+ lines := strings.Split(s, "\n")
195+ kept := lines[:0]
196+ for _, l := range lines {
197+ if !strings.HasPrefix(strings.TrimSpace(l), "remote:") {
198+ kept = append(kept, l)
199+ }
200+ }
201+ return strings.Join(kept, "\n")
202+}
203+
189204 func localGitError(stderr string) bool {
190205 for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {
191206 if strings.Contains(strings.ToLower(stderr), marker) {
@@ -195,6 +210,9 @@ func localGitError(stderr string) bool {
195210 return false
196211 }
197212
213+// maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired.
214+const maxDetail = 4096
215+
198216 // safeDetail removes the complete target before taking a bounded stderr tail.
199217 // SSH diagnostics may repeat the user separately, so suppress user@ tokens too.
200218 func safeDetail(s, url string) string {
@@ -206,10 +224,13 @@ func safeDetail(s, url string) string {
206224 }
207225 }
208226 s = strings.Join(words, " ")
209- if len(s) > 4096 {
210- s = s[len(s)-4096:]
227+ if len(s) > maxDetail {
228+ s = s[len(s)-maxDetail:]
229+ for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character
230+ s = s[1:]
231+ }
211232 }
212- return s
233+ return strings.ToValidUTF8(s, "?")
213234 }
214235
215236 type limitWriter struct {
@@ -14,6 +14,7 @@ import (
14 "strings"14 "strings"
15 "time"15 "time"
16 "unicode"16 "unicode"
17+ "unicode/utf8"
17 18
18 "bnema.dev/ermite/internal/domain"19 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"20 "bnema.dev/ermite/internal/ports"
@@ -121,7 +122,7 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
121 // -F /dev/null prevents ~/.ssh/config and system config from adding credentials122 // -F /dev/null prevents ~/.ssh/config and system config from adding credentials
122 // or weakening the required options. Required options precede caller extras.123 // or weakening the required options. Required options precede caller extras.
123 command := "ssh -F /dev/null -i " + quote(keyPath)124 command := "ssh -F /dev/null -i " + quote(keyPath)
124- for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts}, p.cfg.SSHOptions...) {125+ for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts, "ConnectTimeout=30", "ServerAliveInterval=15", "ServerAliveCountMax=3"}, p.cfg.SSHOptions...) {
125 command += " -o " + quote(opt)126 command += " -o " + quote(opt)
126 }127 }
127 cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)128 cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)
@@ -169,7 +170,9 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
169 return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}170 return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}
170 }171 }
171 if runErr != nil || rejected {172 if runErr != nil || rejected {
172- class := domain.ClassifyMirrorOutput(stderr.buf.String()+"\n"+stdout.buf.String(), runErr, false)173+ // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting
174+ // message must not pick the failure class. They stay in the detail.
175+ class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false)
173 if rejected && class == domain.MirrorClassNone {176 if rejected && class == domain.MirrorClassNone {
174 class = domain.MirrorClassDiverged177 class = domain.MirrorClassDiverged
175 }178 }
@@ -186,6 +189,18 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port
186 return result, nil189 return result, nil
187 }190 }
188 191
192+// withoutRemoteLines drops the lines the remote sent through git ("remote: ...").
193+func withoutRemoteLines(s string) string {
194+ lines := strings.Split(s, "\n")
195+ kept := lines[:0]
196+ for _, l := range lines {
197+ if !strings.HasPrefix(strings.TrimSpace(l), "remote:") {
198+ kept = append(kept, l)
199+ }
200+ }
201+ return strings.Join(kept, "\n")
202+}
203+
189 func localGitError(stderr string) bool {204 func localGitError(stderr string) bool {
190 for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {205 for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {
191 if strings.Contains(strings.ToLower(stderr), marker) {206 if strings.Contains(strings.ToLower(stderr), marker) {
@@ -195,6 +210,9 @@ func localGitError(stderr string) bool {
195 return false210 return false
196 }211 }
197 212
213+// maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired.
214+const maxDetail = 4096
215+
198 // safeDetail removes the complete target before taking a bounded stderr tail.216 // safeDetail removes the complete target before taking a bounded stderr tail.
199 // SSH diagnostics may repeat the user separately, so suppress user@ tokens too.217 // SSH diagnostics may repeat the user separately, so suppress user@ tokens too.
200 func safeDetail(s, url string) string {218 func safeDetail(s, url string) string {
@@ -206,10 +224,13 @@ func safeDetail(s, url string) string {
206 }224 }
207 }225 }
208 s = strings.Join(words, " ")226 s = strings.Join(words, " ")
209- if len(s) > 4096 {227+ if len(s) > maxDetail {
210- s = s[len(s)-4096:]228+ s = s[len(s)-maxDetail:]
229+ for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character
230+ s = s[1:]
231+ }
211 }232 }
212- return s233+ return strings.ToValidUTF8(s, "?")
213 }234 }
214 235
215 type limitWriter struct {236 type limitWriter struct {
M internal/adapters/gitpush/gitpush_test.go +39 -1
@@ -13,6 +13,7 @@ import (
1313 "strings"
1414 "testing"
1515 "time"
16+ "unicode/utf8"
1617
1718 "github.com/stretchr/testify/require"
1819
@@ -103,7 +104,10 @@ func TestBoundsAndKeyEnvironment(t *testing.T) {
103104 require.NoError(t, os.WriteFile(script, []byte(body), 0700))
104105 cfg.Bin = script
105106 cfg.MaxOutput = 1024
106- cfg.Timeout = 200 * time.Millisecond
107+ cfg.Timeout = 5 * time.Second
108+ if mode == "timeout" {
109+ cfg.Timeout = 200 * time.Millisecond // the only case that must wait for the deadline
110+ }
107111 t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
108112 ctx := context.Background()
109113 if mode == "cancel" {
@@ -134,6 +138,9 @@ func TestBoundsAndKeyEnvironment(t *testing.T) {
134138 require.Contains(t, string(env), "BatchMode=yes")
135139 require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
136140 require.Contains(t, string(env), "UserKnownHostsFile=")
141+ require.Contains(t, string(env), "ConnectTimeout=30")
142+ require.Contains(t, string(env), "ServerAliveInterval=15")
143+ require.Contains(t, string(env), "ServerAliveCountMax=3")
137144 })
138145 }
139146 }
@@ -157,3 +164,34 @@ func TestSafeDetail(t *testing.T) {
157164 require.NotContains(t, s, "password")
158165 require.NotContains(t, s, "user@")
159166 }
167+
168+func TestSafeDetailKeepsValidUTF8(t *testing.T) {
169+ // The cut falls inside a multibyte character, and the remote sent a stray byte.
170+ in := "\xff" + strings.Repeat("x", 3) + strings.Repeat("é", 4097) + "\xff end"
171+ var s string
172+ require.NotPanics(t, func() { s = safeDetail(in, "ssh://host/repo") })
173+ require.True(t, utf8.ValidString(s))
174+ require.LessOrEqual(t, len(s), maxDetail)
175+ require.True(t, strings.HasSuffix(s, "? end"))
176+}
177+
178+func TestClassifyIgnoresRemoteLines(t *testing.T) {
179+ stderr := "remote: Permission denied (publickey) is what your CI says\nfatal: unable to access: Could not resolve hostname\n"
180+ require.Equal(t, domain.MirrorClassTransient, domain.ClassifyMirrorOutput(withoutRemoteLines(stderr), errors.New("exit 128"), false))
181+ require.Contains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "kept")
182+ require.NotContains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "remote:")
183+}
184+
185+func TestRemoteLinesStayInDetailButNotInClass(t *testing.T) {
186+ cfg, req := setup(t)
187+ req.URL = "file:///unused"
188+ script := filepath.Join(t.TempDir(), "git")
189+ body := "#!/bin/sh\necho 'remote: error: non-fast-forward hook says no' >&2\necho 'fatal: Could not read from remote repository.' >&2\nexit 1\n"
190+ require.NoError(t, os.WriteFile(script, []byte(body), 0700))
191+ cfg.Bin = script
192+ _, err := New(cfg).Push(context.Background(), req)
193+ var me *domain.MirrorError
194+ require.ErrorAs(t, err, &me)
195+ require.Equal(t, domain.MirrorClassTransient, me.Class, "a message from the remote does not pick the class")
196+ require.Contains(t, me.Detail, "remote: error")
197+}
@@ -13,6 +13,7 @@ import (
13 "strings"13 "strings"
14 "testing"14 "testing"
15 "time"15 "time"
16+ "unicode/utf8"
16 17
17 "github.com/stretchr/testify/require"18 "github.com/stretchr/testify/require"
18 19
@@ -103,7 +104,10 @@ func TestBoundsAndKeyEnvironment(t *testing.T) {
103 require.NoError(t, os.WriteFile(script, []byte(body), 0700))104 require.NoError(t, os.WriteFile(script, []byte(body), 0700))
104 cfg.Bin = script105 cfg.Bin = script
105 cfg.MaxOutput = 1024106 cfg.MaxOutput = 1024
106- cfg.Timeout = 200 * time.Millisecond107+ cfg.Timeout = 5 * time.Second
108+ if mode == "timeout" {
109+ cfg.Timeout = 200 * time.Millisecond // the only case that must wait for the deadline
110+ }
107 t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")111 t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
108 ctx := context.Background()112 ctx := context.Background()
109 if mode == "cancel" {113 if mode == "cancel" {
@@ -134,6 +138,9 @@ func TestBoundsAndKeyEnvironment(t *testing.T) {
134 require.Contains(t, string(env), "BatchMode=yes")138 require.Contains(t, string(env), "BatchMode=yes")
135 require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")139 require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
136 require.Contains(t, string(env), "UserKnownHostsFile=")140 require.Contains(t, string(env), "UserKnownHostsFile=")
141+ require.Contains(t, string(env), "ConnectTimeout=30")
142+ require.Contains(t, string(env), "ServerAliveInterval=15")
143+ require.Contains(t, string(env), "ServerAliveCountMax=3")
137 })144 })
138 }145 }
139 }146 }
@@ -157,3 +164,34 @@ func TestSafeDetail(t *testing.T) {
157 require.NotContains(t, s, "password")164 require.NotContains(t, s, "password")
158 require.NotContains(t, s, "user@")165 require.NotContains(t, s, "user@")
159 }166 }
167+
168+func TestSafeDetailKeepsValidUTF8(t *testing.T) {
169+ // The cut falls inside a multibyte character, and the remote sent a stray byte.
170+ in := "\xff" + strings.Repeat("x", 3) + strings.Repeat("é", 4097) + "\xff end"
171+ var s string
172+ require.NotPanics(t, func() { s = safeDetail(in, "ssh://host/repo") })
173+ require.True(t, utf8.ValidString(s))
174+ require.LessOrEqual(t, len(s), maxDetail)
175+ require.True(t, strings.HasSuffix(s, "? end"))
176+}
177+
178+func TestClassifyIgnoresRemoteLines(t *testing.T) {
179+ stderr := "remote: Permission denied (publickey) is what your CI says\nfatal: unable to access: Could not resolve hostname\n"
180+ require.Equal(t, domain.MirrorClassTransient, domain.ClassifyMirrorOutput(withoutRemoteLines(stderr), errors.New("exit 128"), false))
181+ require.Contains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "kept")
182+ require.NotContains(t, withoutRemoteLines("remote: a\n remote: b\nkept"), "remote:")
183+}
184+
185+func TestRemoteLinesStayInDetailButNotInClass(t *testing.T) {
186+ cfg, req := setup(t)
187+ req.URL = "file:///unused"
188+ script := filepath.Join(t.TempDir(), "git")
189+ body := "#!/bin/sh\necho 'remote: error: non-fast-forward hook says no' >&2\necho 'fatal: Could not read from remote repository.' >&2\nexit 1\n"
190+ require.NoError(t, os.WriteFile(script, []byte(body), 0700))
191+ cfg.Bin = script
192+ _, err := New(cfg).Push(context.Background(), req)
193+ var me *domain.MirrorError
194+ require.ErrorAs(t, err, &me)
195+ require.Equal(t, domain.MirrorClassTransient, me.Class, "a message from the remote does not pick the class")
196+ require.Contains(t, me.Detail, "remote: error")
197+}
A internal/adapters/gitpush/run_other.go +33 -0
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// run starts cmd and waits for it, killing the process (not its group: only
13+// Linux has the guarantee that makes killing a group safe, see run_linux.go)
14+// when ctx is done. cmd.WaitDelay stays the backstop for helpers that hold the
15+// pipes.
16+func run(ctx context.Context, cmd *exec.Cmd) error {
17+ if err := ctx.Err(); err != nil {
18+ return err
19+ }
20+ if err := cmd.Start(); err != nil {
21+ return err
22+ }
23+ done := make(chan struct{})
24+ defer close(done)
25+ go func() {
26+ select {
27+ case <-ctx.Done():
28+ _ = cmd.Process.Kill()
29+ case <-done:
30+ }
31+ }()
32+ return cmd.Wait()
33+}
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// run starts cmd and waits for it, killing the process (not its group: only
13+// Linux has the guarantee that makes killing a group safe, see run_linux.go)
14+// when ctx is done. cmd.WaitDelay stays the backstop for helpers that hold the
15+// pipes.
16+func run(ctx context.Context, cmd *exec.Cmd) error {
17+ if err := ctx.Err(); err != nil {
18+ return err
19+ }
20+ if err := cmd.Start(); err != nil {
21+ return err
22+ }
23+ done := make(chan struct{})
24+ defer close(done)
25+ go func() {
26+ select {
27+ case <-ctx.Done():
28+ _ = cmd.Process.Kill()
29+ case <-done:
30+ }
31+ }()
32+ return cmd.Wait()
33+}
M internal/adapters/kvstore/export_test.go +6 -0
@@ -65,3 +65,9 @@ func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n
6565 _, err := i.db.Apply(ctx, &b)
6666 return err
6767 }
68+
69+// RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests.
70+func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
71+ item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
72+ return item.Value, ok
73+}
@@ -65,3 +65,9 @@ func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n
65 _, err := i.db.Apply(ctx, &b)65 _, err := i.db.Apply(ctx, &b)
66 return err66 return err
67 }67 }
68+
69+// RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests.
70+func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
71+ item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
72+ return item.Value, ok
73+}
M internal/adapters/kvstore/mirror.go +19 -8
@@ -48,7 +48,10 @@ func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) er
4848 }
4949
5050 func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error {
51- val := mustMarshal(pb)
51+ val, err := marshalMirror(pb)
52+ if err != nil {
53+ return err
54+ }
5255 for range updateAttempts {
5356 var b kv.Batch
5457 if key != mirrorConfigKey {
@@ -69,6 +72,16 @@ func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoNam
6972 return domain.ErrConflict
7073 }
7174
75+// marshalMirror refuses what protobuf cannot encode: a status detail comes from remote output
76+// and may hold invalid UTF-8, which would make mustMarshal panic.
77+func marshalMirror(pb proto.Message) ([]byte, error) {
78+ val, err := proto.MarshalOptions{Deterministic: true}.Marshal(pb)
79+ if err != nil {
80+ return nil, fmt.Errorf("%w: mirror record is not valid UTF-8", domain.ErrInvalid)
81+ }
82+ return val, nil
83+}
84+
7285 func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {
7386 var pb storagepb.MirrorGlobal
7487 err := s.readMirror(ctx, mirrorConfigKey, &pb)
@@ -124,6 +137,10 @@ func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.Mir
124137 return decodeMirrorStatus(repo, &pb), err
125138 }
126139 func (s *Store) PutStatus(ctx context.Context, c domain.MirrorStatus) error {
140+ // Detail is the tail of a remote's stderr, whose bytes are not ours: repair rather than lose
141+ // the status (which would stall the incident).
142+ c.Detail = strings.ToValidUTF8(c.Detail, "?")
143+ c.Reason = strings.ToValidUTF8(c.Reason, "?")
127144 return s.writeMirror(ctx, mirrorStatePrefix+c.Repo.String(), c.Repo, &storagepb.MirrorState{State: string(c.State), Class: string(c.Class), Reason: c.Reason, Detail: c.Detail, ConsecutiveFailures: int32(c.ConsecutiveFailures), LastAttemptAt: encodeTime(c.LastAttemptAt), LastSuccessAt: encodeTime(c.LastSuccessAt), NextRetryAt: encodeTime(c.NextRetryAt), PausedAt: encodeTime(c.PausedAt), UpdatedAt: encodeTime(c.UpdatedAt)})
128145 }
129146 func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {
@@ -172,7 +189,7 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i
172189 if ifAbsent {
173190 b.RequireAbsent(key)
174191 }
175- b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)}))
192+ b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) // bytes and a timestamp: always encodable
176193 _, err := s.db.Apply(ctx, &b)
177194 if ifAbsent && errors.Is(err, kv.ErrConflict) {
178195 return domain.ErrAlreadyExists
@@ -182,9 +199,3 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i
182199 }
183200 return nil
184201 }
185-
186-// RawMirrorKey exposes the stored protobuf for encryption-at-rest tests.
187-func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
188- item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
189- return item.Value, ok
190-}
@@ -48,7 +48,10 @@ func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) er
48 }48 }
49 49
50 func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error {50 func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error {
51- val := mustMarshal(pb)51+ val, err := marshalMirror(pb)
52+ if err != nil {
53+ return err
54+ }
52 for range updateAttempts {55 for range updateAttempts {
53 var b kv.Batch56 var b kv.Batch
54 if key != mirrorConfigKey {57 if key != mirrorConfigKey {
@@ -69,6 +72,16 @@ func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoNam
69 return domain.ErrConflict72 return domain.ErrConflict
70 }73 }
71 74
75+// marshalMirror refuses what protobuf cannot encode: a status detail comes from remote output
76+// and may hold invalid UTF-8, which would make mustMarshal panic.
77+func marshalMirror(pb proto.Message) ([]byte, error) {
78+ val, err := proto.MarshalOptions{Deterministic: true}.Marshal(pb)
79+ if err != nil {
80+ return nil, fmt.Errorf("%w: mirror record is not valid UTF-8", domain.ErrInvalid)
81+ }
82+ return val, nil
83+}
84+
72 func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {85 func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {
73 var pb storagepb.MirrorGlobal86 var pb storagepb.MirrorGlobal
74 err := s.readMirror(ctx, mirrorConfigKey, &pb)87 err := s.readMirror(ctx, mirrorConfigKey, &pb)
@@ -124,6 +137,10 @@ func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.Mir
124 return decodeMirrorStatus(repo, &pb), err137 return decodeMirrorStatus(repo, &pb), err
125 }138 }
126 func (s *Store) PutStatus(ctx context.Context, c domain.MirrorStatus) error {139 func (s *Store) PutStatus(ctx context.Context, c domain.MirrorStatus) error {
140+ // Detail is the tail of a remote's stderr, whose bytes are not ours: repair rather than lose
141+ // the status (which would stall the incident).
142+ c.Detail = strings.ToValidUTF8(c.Detail, "?")
143+ c.Reason = strings.ToValidUTF8(c.Reason, "?")
127 return s.writeMirror(ctx, mirrorStatePrefix+c.Repo.String(), c.Repo, &storagepb.MirrorState{State: string(c.State), Class: string(c.Class), Reason: c.Reason, Detail: c.Detail, ConsecutiveFailures: int32(c.ConsecutiveFailures), LastAttemptAt: encodeTime(c.LastAttemptAt), LastSuccessAt: encodeTime(c.LastSuccessAt), NextRetryAt: encodeTime(c.NextRetryAt), PausedAt: encodeTime(c.PausedAt), UpdatedAt: encodeTime(c.UpdatedAt)})144 return s.writeMirror(ctx, mirrorStatePrefix+c.Repo.String(), c.Repo, &storagepb.MirrorState{State: string(c.State), Class: string(c.Class), Reason: c.Reason, Detail: c.Detail, ConsecutiveFailures: int32(c.ConsecutiveFailures), LastAttemptAt: encodeTime(c.LastAttemptAt), LastSuccessAt: encodeTime(c.LastSuccessAt), NextRetryAt: encodeTime(c.NextRetryAt), PausedAt: encodeTime(c.PausedAt), UpdatedAt: encodeTime(c.UpdatedAt)})
128 }145 }
129 func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {146 func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {
@@ -172,7 +189,7 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i
172 if ifAbsent {189 if ifAbsent {
173 b.RequireAbsent(key)190 b.RequireAbsent(key)
174 }191 }
175- b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)}))192+ b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) // bytes and a timestamp: always encodable
176 _, err := s.db.Apply(ctx, &b)193 _, err := s.db.Apply(ctx, &b)
177 if ifAbsent && errors.Is(err, kv.ErrConflict) {194 if ifAbsent && errors.Is(err, kv.ErrConflict) {
178 return domain.ErrAlreadyExists195 return domain.ErrAlreadyExists
@@ -182,9 +199,3 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i
182 }199 }
183 return nil200 return nil
184 }201 }
185-
186-// RawMirrorKey exposes the stored protobuf for encryption-at-rest tests.
187-func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
188- item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
189- return item.Value, ok
190-}
M internal/adapters/kvstore/mirror_test.go +27 -0
@@ -5,7 +5,9 @@ package kvstore_test
55 import (
66 "bytes"
77 "context"
8+ "strings"
89 "testing"
10+ "unicode/utf8"
911
1012 "github.com/stretchr/testify/require"
1113
@@ -96,3 +98,28 @@ func TestMirrorRoundTripsAndDelete(t *testing.T) {
9698 _, err = s.GetStatus(ctx, repo)
9799 require.ErrorIs(t, err, domain.ErrNotFound)
98100 }
101+
102+func TestMirrorStatusInvalidUTF8IsRepairedNotPanicking(t *testing.T) {
103+ ctx := context.Background()
104+ s := openStore(t, t.TempDir())
105+ repo := name(t, "mirror")
106+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
107+ detail := strings.Repeat("é", 2048) + "\xff tail"
108+ require.NotPanics(t, func() {
109+ require.NoError(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Detail: detail, Reason: "bad \xfe"}))
110+ })
111+ got, err := s.GetStatus(ctx, repo)
112+ require.NoError(t, err)
113+ require.True(t, utf8.ValidString(got.Detail))
114+ require.True(t, utf8.ValidString(got.Reason))
115+ require.Contains(t, got.Detail, "? tail")
116+ // Other records refuse invalid text instead of panicking.
117+ require.NotPanics(t, func() {
118+ err := s.PutGlobal(ctx, domain.MirrorGlobalConfig{URLTemplate: "\xff"})
119+ require.ErrorIs(t, err, domain.ErrInvalid)
120+ })
121+ require.NotPanics(t, func() {
122+ err := s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo, URL: "\xff"})
123+ require.ErrorIs(t, err, domain.ErrInvalid)
124+ })
125+}
@@ -5,7 +5,9 @@ package kvstore_test
5 import (5 import (
6 "bytes"6 "bytes"
7 "context"7 "context"
8+ "strings"
8 "testing"9 "testing"
10+ "unicode/utf8"
9 11
10 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
11 13
@@ -96,3 +98,28 @@ func TestMirrorRoundTripsAndDelete(t *testing.T) {
96 _, err = s.GetStatus(ctx, repo)98 _, err = s.GetStatus(ctx, repo)
97 require.ErrorIs(t, err, domain.ErrNotFound)99 require.ErrorIs(t, err, domain.ErrNotFound)
98 }100 }
101+
102+func TestMirrorStatusInvalidUTF8IsRepairedNotPanicking(t *testing.T) {
103+ ctx := context.Background()
104+ s := openStore(t, t.TempDir())
105+ repo := name(t, "mirror")
106+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
107+ detail := strings.Repeat("é", 2048) + "\xff tail"
108+ require.NotPanics(t, func() {
109+ require.NoError(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Detail: detail, Reason: "bad \xfe"}))
110+ })
111+ got, err := s.GetStatus(ctx, repo)
112+ require.NoError(t, err)
113+ require.True(t, utf8.ValidString(got.Detail))
114+ require.True(t, utf8.ValidString(got.Reason))
115+ require.Contains(t, got.Detail, "? tail")
116+ // Other records refuse invalid text instead of panicking.
117+ require.NotPanics(t, func() {
118+ err := s.PutGlobal(ctx, domain.MirrorGlobalConfig{URLTemplate: "\xff"})
119+ require.ErrorIs(t, err, domain.ErrInvalid)
120+ })
121+ require.NotPanics(t, func() {
122+ err := s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo, URL: "\xff"})
123+ require.ErrorIs(t, err, domain.ErrInvalid)
124+ })
125+}
M internal/adapters/rpc/convert.go +1 -1
@@ -525,7 +525,7 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
525525 out.Kind = jobEventToPB(ev)
526526 case domain.MirrorFailed:
527527 out.Time = timestamp(ev.At)
528- out.Kind = &pb.Event_MirrorFailed{MirrorFailed: &pb.MirrorFailed{Repo: ev.Repo.String(), State: mirrorStateToPB(ev.State), FailureClass: string(ev.Class), Reason: ev.Reason}}
528+ out.Kind = &pb.Event_MirrorFailed{MirrorFailed: &pb.MirrorFailed{Repo: ev.Repo.String(), State: mirrorStateToPB(ev.State), FailureClass: string(ev.Class)}}
529529 case domain.MirrorRecovered:
530530 out.Time = timestamp(ev.At)
531531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
@@ -525,7 +525,7 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
525 out.Kind = jobEventToPB(ev)525 out.Kind = jobEventToPB(ev)
526 case domain.MirrorFailed:526 case domain.MirrorFailed:
527 out.Time = timestamp(ev.At)527 out.Time = timestamp(ev.At)
528- out.Kind = &pb.Event_MirrorFailed{MirrorFailed: &pb.MirrorFailed{Repo: ev.Repo.String(), State: mirrorStateToPB(ev.State), FailureClass: string(ev.Class), Reason: ev.Reason}}528+ out.Kind = &pb.Event_MirrorFailed{MirrorFailed: &pb.MirrorFailed{Repo: ev.Repo.String(), State: mirrorStateToPB(ev.State), FailureClass: string(ev.Class)}}
529 case domain.MirrorRecovered:529 case domain.MirrorRecovered:
530 out.Time = timestamp(ev.At)530 out.Time = timestamp(ev.At)
531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}531 out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
M internal/adapters/rpc/mirror.go +28 -9
@@ -4,6 +4,7 @@ package rpc
44
55 import (
66 "context"
7+ "errors"
78 "fmt"
89
910 pb "bnema.dev/ermite-api/gen/ermite"
@@ -27,16 +28,11 @@ func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb.
2728 return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil
2829 }
2930 func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) {
30- // The use case owns authorization for both operations.
31- previous, err := s.mirrors.ShowKey(ctx, caller(ctx))
31+ r, err := s.mirrors.RotateKey(ctx, caller(ctx))
3232 if err != nil {
3333 return nil, connectError(ctx, err)
3434 }
35- k, err := s.mirrors.RotateKey(ctx, caller(ctx))
36- if err != nil {
37- return nil, connectError(ctx, err)
38- }
39- return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(k), PreviousFingerprint: previous.Fingerprint}), nil
35+ return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(r.Key), PreviousFingerprint: r.Previous.Fingerprint}), nil
4036 }
4137 func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) {
4238 g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx))
@@ -129,22 +125,45 @@ func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.
129125 if err != nil {
130126 return nil, connectError(ctx, err)
131127 }
128+ result.Repo = name
132129 results = []ports.MirrorSyncResult{result}
133130 }
134131 out := make([]*pb.SyncMirrorResult, 0, len(results))
135132 for _, result := range results {
136- v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Status.Repo)
133+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Repo)
137134 if err != nil {
138135 return nil, connectError(ctx, err)
139136 }
140137 if !result.Queued {
141138 v.Status = result.Status
142139 }
143- out = append(out, &pb.SyncMirrorResult{Repo: result.Status.Repo.String(), Queued: result.Queued, NoOp: result.NoOp, Status: mirrorStatusToPB(v)})
140+ item := &pb.SyncMirrorResult{Repo: result.Repo.String(), Queued: result.Queued, NoOp: result.NoOp, Status: mirrorStatusToPB(v)}
141+ if result.Err != nil {
142+ item.Error = syncErrorText(result.Err)
143+ }
144+ out = append(out, item)
144145 }
145146 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
146147 }
147148
149+// syncErrorText is the human reason a repository failed in SyncAll. The caller is an owner;
150+// a mirror error is already redacted by the transport and carries no URL.
151+func syncErrorText(err error) string {
152+ var me *domain.MirrorError
153+ if errors.As(err, &me) {
154+ return "mirror " + string(me.Class) + ": " + me.Detail
155+ }
156+ switch {
157+ case errors.Is(err, domain.ErrResourceExhausted):
158+ return err.Error()
159+ case errors.Is(err, domain.ErrNotFound):
160+ return "repository not found"
161+ case errors.Is(err, domain.ErrInvalid):
162+ return "invalid mirror configuration"
163+ }
164+ return "sync failed"
165+}
166+
148167 func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey {
149168 return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)}
150169 }
@@ -4,6 +4,7 @@ package rpc
4 4
5 import (5 import (
6 "context"6 "context"
7+ "errors"
7 "fmt"8 "fmt"
8 9
9 pb "bnema.dev/ermite-api/gen/ermite"10 pb "bnema.dev/ermite-api/gen/ermite"
@@ -27,16 +28,11 @@ func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb.
27 return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil28 return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil
28 }29 }
29 func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) {30 func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) {
30- // The use case owns authorization for both operations.31+ r, err := s.mirrors.RotateKey(ctx, caller(ctx))
31- previous, err := s.mirrors.ShowKey(ctx, caller(ctx))
32 if err != nil {32 if err != nil {
33 return nil, connectError(ctx, err)33 return nil, connectError(ctx, err)
34 }34 }
35- k, err := s.mirrors.RotateKey(ctx, caller(ctx))35+ return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(r.Key), PreviousFingerprint: r.Previous.Fingerprint}), nil
36- if err != nil {
37- return nil, connectError(ctx, err)
38- }
39- return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(k), PreviousFingerprint: previous.Fingerprint}), nil
40 }36 }
41 func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) {37 func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) {
42 g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx))38 g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx))
@@ -129,22 +125,45 @@ func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.
129 if err != nil {125 if err != nil {
130 return nil, connectError(ctx, err)126 return nil, connectError(ctx, err)
131 }127 }
128+ result.Repo = name
132 results = []ports.MirrorSyncResult{result}129 results = []ports.MirrorSyncResult{result}
133 }130 }
134 out := make([]*pb.SyncMirrorResult, 0, len(results))131 out := make([]*pb.SyncMirrorResult, 0, len(results))
135 for _, result := range results {132 for _, result := range results {
136- v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Status.Repo)133+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Repo)
137 if err != nil {134 if err != nil {
138 return nil, connectError(ctx, err)135 return nil, connectError(ctx, err)
139 }136 }
140 if !result.Queued {137 if !result.Queued {
141 v.Status = result.Status138 v.Status = result.Status
142 }139 }
143- out = append(out, &pb.SyncMirrorResult{Repo: result.Status.Repo.String(), Queued: result.Queued, NoOp: result.NoOp, Status: mirrorStatusToPB(v)})140+ item := &pb.SyncMirrorResult{Repo: result.Repo.String(), Queued: result.Queued, NoOp: result.NoOp, Status: mirrorStatusToPB(v)}
141+ if result.Err != nil {
142+ item.Error = syncErrorText(result.Err)
143+ }
144+ out = append(out, item)
144 }145 }
145 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil146 return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
146 }147 }
147 148
149+// syncErrorText is the human reason a repository failed in SyncAll. The caller is an owner;
150+// a mirror error is already redacted by the transport and carries no URL.
151+func syncErrorText(err error) string {
152+ var me *domain.MirrorError
153+ if errors.As(err, &me) {
154+ return "mirror " + string(me.Class) + ": " + me.Detail
155+ }
156+ switch {
157+ case errors.Is(err, domain.ErrResourceExhausted):
158+ return err.Error()
159+ case errors.Is(err, domain.ErrNotFound):
160+ return "repository not found"
161+ case errors.Is(err, domain.ErrInvalid):
162+ return "invalid mirror configuration"
163+ }
164+ return "sync failed"
165+}
166+
148 func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey {167 func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey {
149 return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)}168 return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)}
150 }169 }
M internal/adapters/rpc/mirror_test.go +39 -3
@@ -43,7 +43,7 @@ func TestMirrorSyncResponse(t *testing.T) {
4343 require.NoError(t, err)
4444 status := domain.MirrorStatus{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", ConsecutiveFailures: 1}
4545 target := domain.MirrorTarget{Repo: name, Enabled: true, Source: domain.MirrorSourceOverride}
46- service.EXPECT().Sync(mock.Anything, domain.Principal{}, name, true).Return(ports.MirrorSyncResult{Status: status}, nil).Once()
46+ service.EXPECT().Sync(mock.Anything, domain.Principal{}, name, true).Return(ports.MirrorSyncResult{Repo: name, Status: status}, nil).Once()
4747 service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, name).Return(ports.MirrorStatusView{Target: target, Status: status}, nil).Once()
4848 s := &mirrorService{service}
4949 res, err := s.SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{Repo: "repo", Force: true}))
@@ -58,10 +58,10 @@ func TestMirrorEvents(t *testing.T) {
5858 name, err := domain.ParseRepoName("repo")
5959 require.NoError(t, err)
6060 now := time.Now().UTC()
61- failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", At: now}})
61+ failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now}})
6262 require.NotNil(t, failed)
6363 assert.Equal(t, now, failed.Time.AsTime())
64- assert.Equal(t, &pb.MirrorFailed{Repo: "repo", State: pb.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged", Reason: "remote diverged"}, failed.GetMirrorFailed())
64+ assert.Equal(t, &pb.MirrorFailed{Repo: "repo", State: pb.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}, failed.GetMirrorFailed())
6565 recovered := watchEventToPB(ports.WatchEvent{Event: domain.MirrorRecovered{Repo: name, At: now}})
6666 require.NotNil(t, recovered)
6767 assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo)
@@ -69,4 +69,40 @@ func TestMirrorEvents(t *testing.T) {
6969 fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields()
7070 assert.Nil(t, fields.ByName("url"))
7171 assert.Nil(t, fields.ByName("detail"))
72+ assert.Nil(t, fields.ByName("reason"))
73+}
74+
75+func TestMirrorSyncAllReportsPerRepositoryErrors(t *testing.T) {
76+ service := mocks.NewMirrorService(t)
77+ good, bad := repoN(t, "good"), repoN(t, "bad")
78+ service.EXPECT().SyncAll(mock.Anything, domain.Principal{}, false).Return([]ports.MirrorSyncResult{
79+ {Repo: good, Status: domain.MirrorStatus{Repo: good, State: domain.MirrorOK}},
80+ {Repo: bad, Status: domain.MirrorStatus{Repo: bad, State: domain.MirrorPaused}, Err: &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"}},
81+ }, nil).Once()
82+ service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, good).Return(ports.MirrorStatusView{Status: domain.MirrorStatus{Repo: good, State: domain.MirrorOK}}, nil).Once()
83+ service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, bad).Return(ports.MirrorStatusView{Status: domain.MirrorStatus{Repo: bad, State: domain.MirrorPaused}}, nil).Once()
84+ res, err := (&mirrorService{service}).SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{All: true}))
85+ require.NoError(t, err)
86+ require.Len(t, res.Msg.Results, 2)
87+ assert.Empty(t, res.Msg.Results[0].Error)
88+ assert.Equal(t, "mirror diverged: rejected", res.Msg.Results[1].Error)
89+ assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[1].Status.State)
90+}
91+
92+func TestMirrorRotateReturnsPreviousFingerprint(t *testing.T) {
93+ service := mocks.NewMirrorService(t)
94+ service.EXPECT().RotateKey(mock.Anything, domain.Principal{}).Return(ports.MirrorKeyRotation{
95+ Key: domain.MirrorPublicKey{Fingerprint: "SHA256:new"}, Previous: domain.MirrorPublicKey{Fingerprint: "SHA256:old"},
96+ }, nil).Once()
97+ res, err := (&mirrorService{service}).RotateMirrorKey(context.Background(), connect.NewRequest(&pb.RotateMirrorKeyRequest{}))
98+ require.NoError(t, err)
99+ assert.Equal(t, "SHA256:new", res.Msg.Key.Fingerprint)
100+ assert.Equal(t, "SHA256:old", res.Msg.PreviousFingerprint)
101+}
102+
103+func repoN(t *testing.T, s string) domain.RepoName {
104+ t.Helper()
105+ n, err := domain.ParseRepoName(s)
106+ require.NoError(t, err)
107+ return n
72108 }
@@ -43,7 +43,7 @@ func TestMirrorSyncResponse(t *testing.T) {
43 require.NoError(t, err)43 require.NoError(t, err)
44 status := domain.MirrorStatus{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", ConsecutiveFailures: 1}44 status := domain.MirrorStatus{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", ConsecutiveFailures: 1}
45 target := domain.MirrorTarget{Repo: name, Enabled: true, Source: domain.MirrorSourceOverride}45 target := domain.MirrorTarget{Repo: name, Enabled: true, Source: domain.MirrorSourceOverride}
46- service.EXPECT().Sync(mock.Anything, domain.Principal{}, name, true).Return(ports.MirrorSyncResult{Status: status}, nil).Once()46+ service.EXPECT().Sync(mock.Anything, domain.Principal{}, name, true).Return(ports.MirrorSyncResult{Repo: name, Status: status}, nil).Once()
47 service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, name).Return(ports.MirrorStatusView{Target: target, Status: status}, nil).Once()47 service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, name).Return(ports.MirrorStatusView{Target: target, Status: status}, nil).Once()
48 s := &mirrorService{service}48 s := &mirrorService{service}
49 res, err := s.SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{Repo: "repo", Force: true}))49 res, err := s.SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{Repo: "repo", Force: true}))
@@ -58,10 +58,10 @@ func TestMirrorEvents(t *testing.T) {
58 name, err := domain.ParseRepoName("repo")58 name, err := domain.ParseRepoName("repo")
59 require.NoError(t, err)59 require.NoError(t, err)
60 now := time.Now().UTC()60 now := time.Now().UTC()
61- failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", At: now}})61+ failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now}})
62 require.NotNil(t, failed)62 require.NotNil(t, failed)
63 assert.Equal(t, now, failed.Time.AsTime())63 assert.Equal(t, now, failed.Time.AsTime())
64- assert.Equal(t, &pb.MirrorFailed{Repo: "repo", State: pb.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged", Reason: "remote diverged"}, failed.GetMirrorFailed())64+ assert.Equal(t, &pb.MirrorFailed{Repo: "repo", State: pb.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}, failed.GetMirrorFailed())
65 recovered := watchEventToPB(ports.WatchEvent{Event: domain.MirrorRecovered{Repo: name, At: now}})65 recovered := watchEventToPB(ports.WatchEvent{Event: domain.MirrorRecovered{Repo: name, At: now}})
66 require.NotNil(t, recovered)66 require.NotNil(t, recovered)
67 assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo)67 assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo)
@@ -69,4 +69,40 @@ func TestMirrorEvents(t *testing.T) {
69 fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields()69 fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields()
70 assert.Nil(t, fields.ByName("url"))70 assert.Nil(t, fields.ByName("url"))
71 assert.Nil(t, fields.ByName("detail"))71 assert.Nil(t, fields.ByName("detail"))
72+ assert.Nil(t, fields.ByName("reason"))
73+}
74+
75+func TestMirrorSyncAllReportsPerRepositoryErrors(t *testing.T) {
76+ service := mocks.NewMirrorService(t)
77+ good, bad := repoN(t, "good"), repoN(t, "bad")
78+ service.EXPECT().SyncAll(mock.Anything, domain.Principal{}, false).Return([]ports.MirrorSyncResult{
79+ {Repo: good, Status: domain.MirrorStatus{Repo: good, State: domain.MirrorOK}},
80+ {Repo: bad, Status: domain.MirrorStatus{Repo: bad, State: domain.MirrorPaused}, Err: &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"}},
81+ }, nil).Once()
82+ service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, good).Return(ports.MirrorStatusView{Status: domain.MirrorStatus{Repo: good, State: domain.MirrorOK}}, nil).Once()
83+ service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, bad).Return(ports.MirrorStatusView{Status: domain.MirrorStatus{Repo: bad, State: domain.MirrorPaused}}, nil).Once()
84+ res, err := (&mirrorService{service}).SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{All: true}))
85+ require.NoError(t, err)
86+ require.Len(t, res.Msg.Results, 2)
87+ assert.Empty(t, res.Msg.Results[0].Error)
88+ assert.Equal(t, "mirror diverged: rejected", res.Msg.Results[1].Error)
89+ assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[1].Status.State)
90+}
91+
92+func TestMirrorRotateReturnsPreviousFingerprint(t *testing.T) {
93+ service := mocks.NewMirrorService(t)
94+ service.EXPECT().RotateKey(mock.Anything, domain.Principal{}).Return(ports.MirrorKeyRotation{
95+ Key: domain.MirrorPublicKey{Fingerprint: "SHA256:new"}, Previous: domain.MirrorPublicKey{Fingerprint: "SHA256:old"},
96+ }, nil).Once()
97+ res, err := (&mirrorService{service}).RotateMirrorKey(context.Background(), connect.NewRequest(&pb.RotateMirrorKeyRequest{}))
98+ require.NoError(t, err)
99+ assert.Equal(t, "SHA256:new", res.Msg.Key.Fingerprint)
100+ assert.Equal(t, "SHA256:old", res.Msg.PreviousFingerprint)
101+}
102+
103+func repoN(t *testing.T, s string) domain.RepoName {
104+ t.Helper()
105+ n, err := domain.ParseRepoName(s)
106+ require.NoError(t, err)
107+ return n
72 }108 }
M internal/adapters/web/mirror.go +2 -9
@@ -6,27 +6,20 @@ import (
66 "context"
77
88 "bnema.dev/ermite/internal/domain"
9- "bnema.dev/ermite/internal/ports"
109 )
1110
1211 // MirrorBadge is an administrative diagnostic in the summary rail.
1312 type MirrorBadge struct{ State, Reason, Command string }
1413
15-// WithMirrorWarner enables mirror badges. Configure before serving requests.
16-func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server {
17- s.mirrors = w
18- return s
19-}
20-
2114 // mirrorBadge selects presentation only; the source authorizes the lookup and reason.
2215 //
2316 //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
2417 func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
2518 p := principal(ctx)
26- if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
19+ if s.cfg.Mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
2720 return nil
2821 }
29- state, reason := s.mirrors.Badge(ctx, p, repo)
22+ state, reason := s.cfg.Mirrors.Badge(ctx, p, repo)
3023 if state != domain.MirrorFailing && state != domain.MirrorPaused {
3124 return nil
3225 }
@@ -6,27 +6,20 @@ import (
6 "context"6 "context"
7 7
8 "bnema.dev/ermite/internal/domain"8 "bnema.dev/ermite/internal/domain"
9- "bnema.dev/ermite/internal/ports"
10 )9 )
11 10
12 // MirrorBadge is an administrative diagnostic in the summary rail.11 // MirrorBadge is an administrative diagnostic in the summary rail.
13 type MirrorBadge struct{ State, Reason, Command string }12 type MirrorBadge struct{ State, Reason, Command string }
14 13
15-// WithMirrorWarner enables mirror badges. Configure before serving requests.
16-func (s *Server) WithMirrorWarner(w ports.MirrorWarner) *Server {
17- s.mirrors = w
18- return s
19-}
20-
21 // mirrorBadge selects presentation only; the source authorizes the lookup and reason.14 // mirrorBadge selects presentation only; the source authorizes the lookup and reason.
22 //15 //
23 //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator16 //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
24 func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {17 func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
25 p := principal(ctx)18 p := principal(ctx)
26- if s.mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {19+ if s.cfg.Mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
27 return nil20 return nil
28 }21 }
29- state, reason := s.mirrors.Badge(ctx, p, repo)22+ state, reason := s.cfg.Mirrors.Badge(ctx, p, repo)
30 if state != domain.MirrorFailing && state != domain.MirrorPaused {23 if state != domain.MirrorFailing && state != domain.MirrorPaused {
31 return nil24 return nil
32 }25 }
M internal/adapters/web/mirror_test.go +1 -1
@@ -35,7 +35,7 @@ func TestMirrorBadge(t *testing.T) {
3535 if tc.lookup {
3636 w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
3737 }
38- s := (&Server{}).WithMirrorWarner(w)
38+ s := &Server{cfg: Config{Mirrors: w}}
3939 badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
4040 out := renderRail(t, SummaryPage{Mirror: badge})
4141 if tc.shown {
@@ -35,7 +35,7 @@ func TestMirrorBadge(t *testing.T) {
35 if tc.lookup {35 if tc.lookup {
36 w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")36 w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
37 }37 }
38- s := (&Server{}).WithMirrorWarner(w)38+ s := &Server{cfg: Config{Mirrors: w}}
39 badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)39 badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
40 out := renderRail(t, SummaryPage{Mirror: badge})40 out := renderRail(t, SummaryPage{Mirror: badge})
41 if tc.shown {41 if tc.shown {
M internal/adapters/web/web.go +3 -1
@@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second
4747
4848 // Config configures a Server.
4949 type Config struct {
50+ // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51+ // no badge. Leave it unset rather than assigning a typed nil pointer.
52+ Mirrors ports.MirrorWarner
5053 // Repos and Browse are the use cases the pages are built from.
5154 Repos ports.RepoService
5255 Browse ports.BrowseService
@@ -127,7 +130,6 @@ type Config struct {
127130
128131 // Server serves the pages. It is an http.Handler.
129132 type Server struct {
130- mirrors ports.MirrorWarner
131133 cfg Config
132134 instance string
133135 origin string // scheme://host of the public URL, what a POST must come from
@@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second
47 47
48 // Config configures a Server.48 // Config configures a Server.
49 type Config struct {49 type Config struct {
50+ // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51+ // no badge. Leave it unset rather than assigning a typed nil pointer.
52+ Mirrors ports.MirrorWarner
50 // Repos and Browse are the use cases the pages are built from.53 // Repos and Browse are the use cases the pages are built from.
51 Repos ports.RepoService54 Repos ports.RepoService
52 Browse ports.BrowseService55 Browse ports.BrowseService
@@ -127,7 +130,6 @@ type Config struct {
127 130
128 // Server serves the pages. It is an http.Handler.131 // Server serves the pages. It is an http.Handler.
129 type Server struct {132 type Server struct {
130- mirrors ports.MirrorWarner
131 cfg Config133 cfg Config
132 instance string134 instance string
133 origin string // scheme://host of the public URL, what a POST must come from135 origin string // scheme://host of the public URL, what a POST must come from
M internal/app/cli_mirror.go +95 -31
@@ -9,32 +9,87 @@ import (
99
1010 "connectrpc.com/connect"
1111 "github.com/spf13/cobra"
12- "google.golang.org/protobuf/encoding/protojson"
13- "google.golang.org/protobuf/proto"
1412
1513 "bnema.dev/ermite-api/gen/ermite"
1614 )
1715
18-// mirrorJSON keeps the API's snake_case field names, including unset defaults.
19-func mirrorJSON(cmd *cobra.Command, msg proto.Message) error {
20- b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg)
21- if err != nil {
22- return err
16+const jsonMirrorKeyHelp = `
17+JSON fields of a key (--json): authorized_key, fingerprint, created_at (RFC 3339, UTC).`
18+
19+const jsonMirrorConfigHelp = `
20+JSON fields of the policy (--json): enabled, url_template, cron, all_branches, updated_at (RFC 3339, UTC, null if never set).`
21+
22+const jsonMirrorStatusHelp = `
23+JSON fields of a status (--json): repo, enabled, source (disabled, global, override, excluded), url
24+(admins only, else empty), cron, all_branches, state (disabled, ok, failing, paused), failure_class,
25+reason, detail (admins only), consecutive_failures, last_attempt_at, last_success_at, next_retry_at,
26+next_run_at, paused_at (RFC 3339, UTC, null when unset).`
27+
28+type jsonMirrorKey struct {
29+ AuthorizedKey string `json:"authorized_key"`
30+ Fingerprint string `json:"fingerprint"`
31+ CreatedAt *string `json:"created_at"`
32+}
33+
34+type jsonMirrorConfig struct {
35+ Enabled bool `json:"enabled"`
36+ URLTemplate string `json:"url_template"`
37+ Cron string `json:"cron"`
38+ AllBranches bool `json:"all_branches"`
39+ UpdatedAt *string `json:"updated_at"`
40+}
41+
42+type jsonMirrorStatus struct {
43+ Repo string `json:"repo"`
44+ Enabled bool `json:"enabled"`
45+ Source string `json:"source"`
46+ URL string `json:"url"`
47+ Cron string `json:"cron"`
48+ AllBranches bool `json:"all_branches"`
49+ State string `json:"state"`
50+ FailureClass string `json:"failure_class"`
51+ Reason string `json:"reason"`
52+ Detail string `json:"detail"`
53+ ConsecutiveFailures int32 `json:"consecutive_failures"`
54+ LastAttemptAt *string `json:"last_attempt_at"`
55+ LastSuccessAt *string `json:"last_success_at"`
56+ NextRetryAt *string `json:"next_retry_at"`
57+ NextRunAt *string `json:"next_run_at"`
58+ PausedAt *string `json:"paused_at"`
59+}
60+
61+func toJSONMirrorKey(k *ermite.MirrorKey) jsonMirrorKey {
62+ return jsonMirrorKey{AuthorizedKey: k.GetAuthorizedKey(), Fingerprint: k.GetFingerprint(), CreatedAt: jsonTime(k.GetCreatedAt())}
63+}
64+
65+func toJSONMirrorConfig(g *ermite.MirrorConfig) jsonMirrorConfig {
66+ return jsonMirrorConfig{Enabled: g.GetEnabled(), URLTemplate: g.GetUrlTemplate(), Cron: g.GetCron(), AllBranches: g.GetAllBranches(), UpdatedAt: jsonTime(g.GetUpdatedAt())}
67+}
68+
69+func mirrorSourceName(s ermite.MirrorSource) string {
70+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_SOURCE_"))
71+}
72+
73+func toJSONMirrorStatus(s *ermite.MirrorStatus) jsonMirrorStatus {
74+ t := s.GetTarget()
75+ return jsonMirrorStatus{
76+ Repo: t.GetRepo(), Enabled: t.GetEnabled(), Source: mirrorSourceName(t.GetSource()), URL: t.GetUrl(), Cron: t.GetCron(), AllBranches: t.GetAllBranches(),
77+ State: mirrorStateName(s.GetState()), FailureClass: s.GetFailureClass(), Reason: s.GetReason(), Detail: s.GetDetail(), ConsecutiveFailures: s.GetConsecutiveFailures(),
78+ LastAttemptAt: jsonTime(s.GetLastAttemptAt()), LastSuccessAt: jsonTime(s.GetLastSuccessAt()), NextRetryAt: jsonTime(s.GetNextRetryAt()), NextRunAt: jsonTime(s.GetNextRunAt()), PausedAt: jsonTime(s.GetPausedAt()),
2379 }
24- _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b))
25- return err
2680 }
2781
2882 func newMirrorCmd(env *cliEnv) *cobra.Command {
2983 cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
3084 key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
3185 for _, rotate := range []bool{false, true} {
32- name := "show"
86+ name, short := "show", "Print the mirror authorized_keys line"
87+ long := "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe it into: gh ssh-key add - --title \"ermite mirror\"." + jsonMirrorKeyHelp
3388 if rotate {
34- name = "rotate"
89+ name, short = "rotate", "Replace the mirror SSH key (owner only)"
90+ long = "Replace the mirror SSH key at once and print the new authorized_keys line on stdout; the replaced\nkey's fingerprint goes to stderr. Every mirror fails until the new key is added to the remotes.\nRequires owner rights and ERMITE_SECRETS_KEY on the server." + jsonMirrorKeyHelp
3591 }
36- sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs,
37- Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."}
92+ sub := &cobra.Command{Use: name, Short: short, Args: cobra.NoArgs, Long: long}
3893 asJSON := addJSONFlag(sub)
3994 sub.RunE = func(cmd *cobra.Command, _ []string) error {
4095 c, err := env.client(cmd)
@@ -48,6 +103,9 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
48103 return c.Describe(e)
49104 }
50105 k = res.Msg.GetKey()
106+ if prev := res.Msg.GetPreviousFingerprint(); prev != "" {
107+ noticef(cmd, "replaced key %s", clean(prev))
108+ }
51109 noticef(cmd, "remotes refuse pushes until this key is added")
52110 } else {
53111 res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
@@ -57,7 +115,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
57115 k = res.Msg.GetKey()
58116 }
59117 if *asJSON {
60- return mirrorJSON(cmd, k)
118+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k))
61119 }
62120 _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
63121 return err
@@ -91,7 +149,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
91149 func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
92150 var enabled, all bool
93151 var url, cron string
94- cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs}
152+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Long: "Change the flags given and keep the other settings; use `config show` to read the policy." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
95153 cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
96154 cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
97155 cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
@@ -99,7 +157,7 @@ func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
99157 asJSON := addJSONFlag(cmd)
100158 output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
101159 if json {
102- return mirrorJSON(cmd, g)
160+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g))
103161 }
104162 return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
105163 }
@@ -130,7 +188,7 @@ func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
130188 }
131189 return output(cmd, res.Msg.GetConfig(), *asJSON)
132190 }
133- show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs}
191+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Long: "Show the global mirror policy (owner only)." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
134192 showJSON := addJSONFlag(show)
135193 show.RunE = func(cmd *cobra.Command, _ []string) error {
136194 c, err := env.client(cmd)
@@ -177,7 +235,7 @@ func mirrorStateName(s ermite.MirrorState) string {
177235 }
178236
179237 func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
180- cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)}
238+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Long: "Show one repository, or every repository (owner only).\n--json prints an object for one repository and an array for all." + jsonMirrorStatusHelp, Args: cobra.MaximumNArgs(1)}
181239 asJSON := addJSONFlag(cmd)
182240 cmd.RunE = func(cmd *cobra.Command, args []string) error {
183241 c, err := env.client(cmd)
@@ -191,7 +249,7 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
191249 return c.Describe(err)
192250 }
193251 if *asJSON {
194- return mirrorJSON(cmd, res.Msg.GetStatus())
252+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus()))
195253 }
196254 statuses = append(statuses, res.Msg.GetStatus())
197255 } else {
@@ -200,17 +258,11 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
200258 return c.Describe(err)
201259 }
202260 if *asJSON {
203- // Marshal each status with proto JSON, but retain the CLI list convention: an array.
204- items := make([]string, 0, len(res.Msg.GetStatuses()))
205- for _, s := range res.Msg.GetStatuses() {
206- b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s)
207- if e != nil {
208- return e
209- }
210- items = append(items, string(b))
261+ items := make([]jsonMirrorStatus, 0, len(res.Msg.GetStatuses()))
262+ for _, st := range res.Msg.GetStatuses() {
263+ items = append(items, toJSONMirrorStatus(st))
211264 }
212- _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]")
213- return err
265+ return writeJSON(cmd.OutOrStdout(), items)
214266 }
215267 statuses = res.Msg.GetStatuses()
216268 }
@@ -246,11 +298,23 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
246298 if err != nil {
247299 return c.Describe(err)
248300 }
301+ failed := 0
249302 rows := make([][]string, 0, len(res.Msg.GetResults()))
250303 for _, r := range res.Msg.GetResults() {
251- rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()})
304+ reason := r.GetStatus().GetReason()
305+ if r.GetError() != "" {
306+ failed++
307+ reason = r.GetError()
308+ }
309+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
310+ }
311+ if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312+ return err
252313 }
253- return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows)
314+ if failed > 0 {
315+ return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316+ }
317+ return nil
254318 }
255319 return cmd
256320 }
@@ -9,32 +9,87 @@ import (
9 9
10 "connectrpc.com/connect"10 "connectrpc.com/connect"
11 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
12- "google.golang.org/protobuf/encoding/protojson"
13- "google.golang.org/protobuf/proto"
14 12
15 "bnema.dev/ermite-api/gen/ermite"13 "bnema.dev/ermite-api/gen/ermite"
16 )14 )
17 15
18-// mirrorJSON keeps the API's snake_case field names, including unset defaults.16+const jsonMirrorKeyHelp = `
19-func mirrorJSON(cmd *cobra.Command, msg proto.Message) error {17+JSON fields of a key (--json): authorized_key, fingerprint, created_at (RFC 3339, UTC).`
20- b, err := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(msg)18+
21- if err != nil {19+const jsonMirrorConfigHelp = `
22- return err20+JSON fields of the policy (--json): enabled, url_template, cron, all_branches, updated_at (RFC 3339, UTC, null if never set).`
21+
22+const jsonMirrorStatusHelp = `
23+JSON fields of a status (--json): repo, enabled, source (disabled, global, override, excluded), url
24+(admins only, else empty), cron, all_branches, state (disabled, ok, failing, paused), failure_class,
25+reason, detail (admins only), consecutive_failures, last_attempt_at, last_success_at, next_retry_at,
26+next_run_at, paused_at (RFC 3339, UTC, null when unset).`
27+
28+type jsonMirrorKey struct {
29+ AuthorizedKey string `json:"authorized_key"`
30+ Fingerprint string `json:"fingerprint"`
31+ CreatedAt *string `json:"created_at"`
32+}
33+
34+type jsonMirrorConfig struct {
35+ Enabled bool `json:"enabled"`
36+ URLTemplate string `json:"url_template"`
37+ Cron string `json:"cron"`
38+ AllBranches bool `json:"all_branches"`
39+ UpdatedAt *string `json:"updated_at"`
40+}
41+
42+type jsonMirrorStatus struct {
43+ Repo string `json:"repo"`
44+ Enabled bool `json:"enabled"`
45+ Source string `json:"source"`
46+ URL string `json:"url"`
47+ Cron string `json:"cron"`
48+ AllBranches bool `json:"all_branches"`
49+ State string `json:"state"`
50+ FailureClass string `json:"failure_class"`
51+ Reason string `json:"reason"`
52+ Detail string `json:"detail"`
53+ ConsecutiveFailures int32 `json:"consecutive_failures"`
54+ LastAttemptAt *string `json:"last_attempt_at"`
55+ LastSuccessAt *string `json:"last_success_at"`
56+ NextRetryAt *string `json:"next_retry_at"`
57+ NextRunAt *string `json:"next_run_at"`
58+ PausedAt *string `json:"paused_at"`
59+}
60+
61+func toJSONMirrorKey(k *ermite.MirrorKey) jsonMirrorKey {
62+ return jsonMirrorKey{AuthorizedKey: k.GetAuthorizedKey(), Fingerprint: k.GetFingerprint(), CreatedAt: jsonTime(k.GetCreatedAt())}
63+}
64+
65+func toJSONMirrorConfig(g *ermite.MirrorConfig) jsonMirrorConfig {
66+ return jsonMirrorConfig{Enabled: g.GetEnabled(), URLTemplate: g.GetUrlTemplate(), Cron: g.GetCron(), AllBranches: g.GetAllBranches(), UpdatedAt: jsonTime(g.GetUpdatedAt())}
67+}
68+
69+func mirrorSourceName(s ermite.MirrorSource) string {
70+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_SOURCE_"))
71+}
72+
73+func toJSONMirrorStatus(s *ermite.MirrorStatus) jsonMirrorStatus {
74+ t := s.GetTarget()
75+ return jsonMirrorStatus{
76+ Repo: t.GetRepo(), Enabled: t.GetEnabled(), Source: mirrorSourceName(t.GetSource()), URL: t.GetUrl(), Cron: t.GetCron(), AllBranches: t.GetAllBranches(),
77+ State: mirrorStateName(s.GetState()), FailureClass: s.GetFailureClass(), Reason: s.GetReason(), Detail: s.GetDetail(), ConsecutiveFailures: s.GetConsecutiveFailures(),
78+ LastAttemptAt: jsonTime(s.GetLastAttemptAt()), LastSuccessAt: jsonTime(s.GetLastSuccessAt()), NextRetryAt: jsonTime(s.GetNextRetryAt()), NextRunAt: jsonTime(s.GetNextRunAt()), PausedAt: jsonTime(s.GetPausedAt()),
23 }79 }
24- _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b))
25- return err
26 }80 }
27 81
28 func newMirrorCmd(env *cliEnv) *cobra.Command {82 func newMirrorCmd(env *cliEnv) *cobra.Command {
29 cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}83 cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
30 key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}84 key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
31 for _, rotate := range []bool{false, true} {85 for _, rotate := range []bool{false, true} {
32- name := "show"86+ name, short := "show", "Print the mirror authorized_keys line"
87+ long := "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe it into: gh ssh-key add - --title \"ermite mirror\"." + jsonMirrorKeyHelp
33 if rotate {88 if rotate {
34- name = "rotate"89+ name, short = "rotate", "Replace the mirror SSH key (owner only)"
90+ long = "Replace the mirror SSH key at once and print the new authorized_keys line on stdout; the replaced\nkey's fingerprint goes to stderr. Every mirror fails until the new key is added to the remotes.\nRequires owner rights and ERMITE_SECRETS_KEY on the server." + jsonMirrorKeyHelp
35 }91 }
36- sub := &cobra.Command{Use: name, Short: "Print the mirror authorized_keys line", Args: cobra.NoArgs,92+ sub := &cobra.Command{Use: name, Short: short, Args: cobra.NoArgs, Long: long}
37- Long: "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe show into: gh ssh-key add - --title \"ermite mirror\". Rotation requires owner rights and takes effect immediately."}
38 asJSON := addJSONFlag(sub)93 asJSON := addJSONFlag(sub)
39 sub.RunE = func(cmd *cobra.Command, _ []string) error {94 sub.RunE = func(cmd *cobra.Command, _ []string) error {
40 c, err := env.client(cmd)95 c, err := env.client(cmd)
@@ -48,6 +103,9 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
48 return c.Describe(e)103 return c.Describe(e)
49 }104 }
50 k = res.Msg.GetKey()105 k = res.Msg.GetKey()
106+ if prev := res.Msg.GetPreviousFingerprint(); prev != "" {
107+ noticef(cmd, "replaced key %s", clean(prev))
108+ }
51 noticef(cmd, "remotes refuse pushes until this key is added")109 noticef(cmd, "remotes refuse pushes until this key is added")
52 } else {110 } else {
53 res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))111 res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
@@ -57,7 +115,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
57 k = res.Msg.GetKey()115 k = res.Msg.GetKey()
58 }116 }
59 if *asJSON {117 if *asJSON {
60- return mirrorJSON(cmd, k)118+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k))
61 }119 }
62 _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))120 _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
63 return err121 return err
@@ -91,7 +149,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command {
91 func newMirrorConfigCmd(env *cliEnv) *cobra.Command {149 func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
92 var enabled, all bool150 var enabled, all bool
93 var url, cron string151 var url, cron string
94- cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Args: cobra.NoArgs}152+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Long: "Change the flags given and keep the other settings; use `config show` to read the policy." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
95 cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")153 cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
96 cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")154 cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
97 cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")155 cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
@@ -99,7 +157,7 @@ func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
99 asJSON := addJSONFlag(cmd)157 asJSON := addJSONFlag(cmd)
100 output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {158 output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
101 if json {159 if json {
102- return mirrorJSON(cmd, g)160+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g))
103 }161 }
104 return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})162 return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
105 }163 }
@@ -130,7 +188,7 @@ func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
130 }188 }
131 return output(cmd, res.Msg.GetConfig(), *asJSON)189 return output(cmd, res.Msg.GetConfig(), *asJSON)
132 }190 }
133- show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Args: cobra.NoArgs}191+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Long: "Show the global mirror policy (owner only)." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
134 showJSON := addJSONFlag(show)192 showJSON := addJSONFlag(show)
135 show.RunE = func(cmd *cobra.Command, _ []string) error {193 show.RunE = func(cmd *cobra.Command, _ []string) error {
136 c, err := env.client(cmd)194 c, err := env.client(cmd)
@@ -177,7 +235,7 @@ func mirrorStateName(s ermite.MirrorState) string {
177 }235 }
178 236
179 func newMirrorStatusCmd(env *cliEnv) *cobra.Command {237 func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
180- cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Args: cobra.MaximumNArgs(1)}238+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Long: "Show one repository, or every repository (owner only).\n--json prints an object for one repository and an array for all." + jsonMirrorStatusHelp, Args: cobra.MaximumNArgs(1)}
181 asJSON := addJSONFlag(cmd)239 asJSON := addJSONFlag(cmd)
182 cmd.RunE = func(cmd *cobra.Command, args []string) error {240 cmd.RunE = func(cmd *cobra.Command, args []string) error {
183 c, err := env.client(cmd)241 c, err := env.client(cmd)
@@ -191,7 +249,7 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
191 return c.Describe(err)249 return c.Describe(err)
192 }250 }
193 if *asJSON {251 if *asJSON {
194- return mirrorJSON(cmd, res.Msg.GetStatus())252+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus()))
195 }253 }
196 statuses = append(statuses, res.Msg.GetStatus())254 statuses = append(statuses, res.Msg.GetStatus())
197 } else {255 } else {
@@ -200,17 +258,11 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
200 return c.Describe(err)258 return c.Describe(err)
201 }259 }
202 if *asJSON {260 if *asJSON {
203- // Marshal each status with proto JSON, but retain the CLI list convention: an array.261+ items := make([]jsonMirrorStatus, 0, len(res.Msg.GetStatuses()))
204- items := make([]string, 0, len(res.Msg.GetStatuses()))262+ for _, st := range res.Msg.GetStatuses() {
205- for _, s := range res.Msg.GetStatuses() {263+ items = append(items, toJSONMirrorStatus(st))
206- b, e := (protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}).Marshal(s)
207- if e != nil {
208- return e
209- }
210- items = append(items, string(b))
211 }264 }
212- _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]")265+ return writeJSON(cmd.OutOrStdout(), items)
213- return err
214 }266 }
215 statuses = res.Msg.GetStatuses()267 statuses = res.Msg.GetStatuses()
216 }268 }
@@ -246,11 +298,23 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
246 if err != nil {298 if err != nil {
247 return c.Describe(err)299 return c.Describe(err)
248 }300 }
301+ failed := 0
249 rows := make([][]string, 0, len(res.Msg.GetResults()))302 rows := make([][]string, 0, len(res.Msg.GetResults()))
250 for _, r := range res.Msg.GetResults() {303 for _, r := range res.Msg.GetResults() {
251- rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), r.GetStatus().GetReason()})304+ reason := r.GetStatus().GetReason()
305+ if r.GetError() != "" {
306+ failed++
307+ reason = r.GetError()
308+ }
309+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
310+ }
311+ if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312+ return err
252 }313 }
253- return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows)314+ if failed > 0 {
315+ return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316+ }
317+ return nil
254 }318 }
255 return cmd319 return cmd
256 }320 }
M internal/app/cli_mirror_e2e_test.go +3 -2
@@ -29,7 +29,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
2929 var dataDir string
3030 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
3131 o.mirrors = func(c *usecase.MirrorsConfig) {
32- c.AllowAnyURL = true
32+ c.ValidateURL = func(string) error { return nil } // file:// remotes
3333 c.Clock = clk
3434 c.ScheduleTick = time.Hour
3535 c.Pusher = gitpush.New(gitpush.Config{
@@ -56,6 +56,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
5656 assert.Equal(t, "ssh-ed25519", pub.Type())
5757 assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
5858 assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59+ h.forge.Mirrors.Start()
5960 events := h.forge.Bus.Subscribe(h.ctx)
6061 wt := h.workTree("main", "README.md")
6162 h.mustGit(wt, "tag", "v1")
@@ -88,7 +89,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
8889 status := run("status", "proj").ok(t).stdout
8990 assert.Contains(t, status, "paused")
9091 assert.Contains(t, status, "remote has commits")
91- assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`)
92+ assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class": "diverged"`)
9293 h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
9394 warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
9495 assert.Contains(t, warning, "remote: ermite: mirror")
@@ -29,7 +29,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
29 var dataDir string29 var dataDir string
30 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {30 h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31 o.mirrors = func(c *usecase.MirrorsConfig) {31 o.mirrors = func(c *usecase.MirrorsConfig) {
32- c.AllowAnyURL = true32+ c.ValidateURL = func(string) error { return nil } // file:// remotes
33 c.Clock = clk33 c.Clock = clk
34 c.ScheduleTick = time.Hour34 c.ScheduleTick = time.Hour
35 c.Pusher = gitpush.New(gitpush.Config{35 c.Pusher = gitpush.New(gitpush.Config{
@@ -56,6 +56,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
56 assert.Equal(t, "ssh-ed25519", pub.Type())56 assert.Equal(t, "ssh-ed25519", pub.Type())
57 assert.Equal(t, 1, strings.Count(key.stdout, "\n"))57 assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58 assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")58 assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59+ h.forge.Mirrors.Start()
59 events := h.forge.Bus.Subscribe(h.ctx)60 events := h.forge.Bus.Subscribe(h.ctx)
60 wt := h.workTree("main", "README.md")61 wt := h.workTree("main", "README.md")
61 h.mustGit(wt, "tag", "v1")62 h.mustGit(wt, "tag", "v1")
@@ -88,7 +89,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) {
88 status := run("status", "proj").ok(t).stdout89 status := run("status", "proj").ok(t).stdout
89 assert.Contains(t, status, "paused")90 assert.Contains(t, status, "paused")
90 assert.Contains(t, status, "remote has commits")91 assert.Contains(t, status, "remote has commits")
91- assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class":"diverged"`)92+ assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class": "diverged"`)
92 h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")93 h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
93 warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")94 warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
94 assert.Contains(t, warning, "remote: ermite: mirror")95 assert.Contains(t, warning, "remote: ermite: mirror")
M internal/app/cli_mirror_test.go +7 -4
@@ -44,19 +44,22 @@ func TestMirrorCLIConfiguration(t *testing.T) {
4444 run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
4545 r = run("status", "proj", "--json").ok(t)
4646 s := decode[map[string]any](t, r.stdout)
47- target := s["target"].(map[string]any)
48- assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"])
49- assert.Equal(t, false, target["all_branches"])
47+ assert.Equal(t, "override", s["source"])
48+ assert.Equal(t, false, s["all_branches"])
49+ assert.Equal(t, "ok", s["state"])
50+ assert.Equal(t, "proj", s["repo"])
5051 assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
5152 assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
5253 run("remove", "proj").ok(t)
5354 run("exclude", "proj").ok(t)
5455 s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
55- assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"])
56+ assert.Equal(t, "excluded", s["source"])
5657 run("include", "proj").ok(t)
5758 first := run("key", "show").ok(t)
5859 assert.Empty(t, first.stderr)
60+ firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string)
5961 rotated := run("key", "rotate").ok(t)
62+ assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint)
6063 assert.NotEqual(t, first.stdout, rotated.stdout)
6164 assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
6265 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
@@ -44,19 +44,22 @@ func TestMirrorCLIConfiguration(t *testing.T) {
44 run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)44 run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
45 r = run("status", "proj", "--json").ok(t)45 r = run("status", "proj", "--json").ok(t)
46 s := decode[map[string]any](t, r.stdout)46 s := decode[map[string]any](t, r.stdout)
47- target := s["target"].(map[string]any)47+ assert.Equal(t, "override", s["source"])
48- assert.Equal(t, "MIRROR_SOURCE_OVERRIDE", target["source"])48+ assert.Equal(t, false, s["all_branches"])
49- assert.Equal(t, false, target["all_branches"])49+ assert.Equal(t, "ok", s["state"])
50+ assert.Equal(t, "proj", s["repo"])
50 assert.Contains(t, run("status").ok(t).stdout, "FAILURES")51 assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
51 assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)52 assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
52 run("remove", "proj").ok(t)53 run("remove", "proj").ok(t)
53 run("exclude", "proj").ok(t)54 run("exclude", "proj").ok(t)
54 s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)55 s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
55- assert.Equal(t, "MIRROR_SOURCE_EXCLUDED", s["target"].(map[string]any)["source"])56+ assert.Equal(t, "excluded", s["source"])
56 run("include", "proj").ok(t)57 run("include", "proj").ok(t)
57 first := run("key", "show").ok(t)58 first := run("key", "show").ok(t)
58 assert.Empty(t, first.stderr)59 assert.Empty(t, first.stderr)
60+ firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string)
59 rotated := run("key", "rotate").ok(t)61 rotated := run("key", "rotate").ok(t)
62+ assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint)
60 assert.NotEqual(t, first.stdout, rotated.stdout)63 assert.NotEqual(t, first.stdout, rotated.stdout)
61 assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")64 assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
62 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)65 assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
M internal/app/cli_watch.go +14 -0
@@ -31,6 +31,9 @@ type jsonEvent struct {
3131 Comment *jsonComment `json:"comment,omitempty"`
3232 // Mergeability is set for a merge_request_mergeability event.
3333 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34+ // State and FailureClass are set for a mirror_failed event.
35+ State string `json:"state,omitempty"`
36+ FailureClass string `json:"failure_class,omitempty"`
3437 }
3538
3639 type jsonRefUpdate struct {
@@ -55,6 +58,8 @@ const (
5558 eventCommentDeleted = "comment_deleted"
5659 eventCommentResolved = "comment_resolved"
5760 eventMRMergeability = "merge_request_mergeability"
61+ eventMirrorFailed = "mirror_failed"
62+ eventMirrorRecovered = "mirror_recovered"
5863 )
5964
6065 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
100105 case *ermite.Event_MergeRequestMergeability:
101106 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102107 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
108+ case *ermite.Event_MirrorFailed:
109+ out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
110+ out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
111+ case *ermite.Event_MirrorRecovered:
112+ out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
103113 case *ermite.Event_Gap:
104114 out.Type = eventGap
105115 default:
@@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string {
134144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135145 case eventMRMergeability:
136146 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
147+ case eventMirrorFailed:
148+ detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
149+ case eventMirrorRecovered:
150+ detail = e.Repo
137151 case eventGap:
138152 detail = "events were lost, reload what you display"
139153 }
@@ -31,6 +31,9 @@ type jsonEvent struct {
31 Comment *jsonComment `json:"comment,omitempty"`31 Comment *jsonComment `json:"comment,omitempty"`
32 // Mergeability is set for a merge_request_mergeability event.32 // Mergeability is set for a merge_request_mergeability event.
33 Mergeability *jsonMergeability `json:"mergeability,omitempty"`33 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34+ // State and FailureClass are set for a mirror_failed event.
35+ State string `json:"state,omitempty"`
36+ FailureClass string `json:"failure_class,omitempty"`
34 }37 }
35 38
36 type jsonRefUpdate struct {39 type jsonRefUpdate struct {
@@ -55,6 +58,8 @@ const (
55 eventCommentDeleted = "comment_deleted"58 eventCommentDeleted = "comment_deleted"
56 eventCommentResolved = "comment_resolved"59 eventCommentResolved = "comment_resolved"
57 eventMRMergeability = "merge_request_mergeability"60 eventMRMergeability = "merge_request_mergeability"
61+ eventMirrorFailed = "mirror_failed"
62+ eventMirrorRecovered = "mirror_recovered"
58 )63 )
59 64
60 // toJSONEvent maps an event; ok is false for kinds this client does not know,65 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
100 case *ermite.Event_MergeRequestMergeability:105 case *ermite.Event_MergeRequestMergeability:
101 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()106 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())107 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
108+ case *ermite.Event_MirrorFailed:
109+ out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
110+ out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
111+ case *ermite.Event_MirrorRecovered:
112+ out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
103 case *ermite.Event_Gap:113 case *ermite.Event_Gap:
104 out.Type = eventGap114 out.Type = eventGap
105 default:115 default:
@@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string {
134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135 case eventMRMergeability:145 case eventMRMergeability:
136 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))146 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
147+ case eventMirrorFailed:
148+ detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
149+ case eventMirrorRecovered:
150+ detail = e.Repo
137 case eventGap:151 case eventGap:
138 detail = "events were lost, reload what you display"152 detail = "events were lost, reload what you display"
139 }153 }
M internal/app/cli_watch_test.go +17 -0
@@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) {
266266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
267267 }
268268 }
269+
270+func TestMirrorWatchEvents(t *testing.T) {
271+ failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "")
272+ require.True(t, ok)
273+ assert.Equal(t, "mirror_failed", failed.Type)
274+ assert.Equal(t, "alpha", failed.Repo)
275+ assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)")
276+ raw, err := json.Marshal(failed)
277+ require.NoError(t, err)
278+ assert.Contains(t, string(raw), `"state":"paused"`)
279+ assert.Contains(t, string(raw), `"failure_class":"diverged"`)
280+ recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "")
281+ require.True(t, ok)
282+ assert.Equal(t, "mirror_recovered", recovered.Type)
283+ assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284+ assert.NotContains(t, eventLine(recovered), " \n")
285+}
@@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) {
266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
267 }267 }
268 }268 }
269+
270+func TestMirrorWatchEvents(t *testing.T) {
271+ failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "")
272+ require.True(t, ok)
273+ assert.Equal(t, "mirror_failed", failed.Type)
274+ assert.Equal(t, "alpha", failed.Repo)
275+ assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)")
276+ raw, err := json.Marshal(failed)
277+ require.NoError(t, err)
278+ assert.Contains(t, string(raw), `"state":"paused"`)
279+ assert.Contains(t, string(raw), `"failure_class":"diverged"`)
280+ recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "")
281+ require.True(t, ok)
282+ assert.Equal(t, "mirror_recovered", recovered.Type)
283+ assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284+ assert.NotContains(t, eventLine(recovered), " \n")
285+}
M internal/app/forge.go +0 -3
@@ -490,9 +490,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
490490 if err != nil {
491491 return nil, fmt.Errorf("web: %w", err)
492492 }
493- if s, ok := site.(*web.Server); ok {
494- s.WithMirrorWarner(f.Mirrors)
495- }
496493 routes = append(routes, webRoutes(site)...)
497494 f.server = httpserver.New(httpserver.Config{
498495 Addr: cfg.Addr,
@@ -490,9 +490,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
490 if err != nil {490 if err != nil {
491 return nil, fmt.Errorf("web: %w", err)491 return nil, fmt.Errorf("web: %w", err)
492 }492 }
493- if s, ok := site.(*web.Server); ok {
494- s.WithMirrorWarner(f.Mirrors)
495- }
496 routes = append(routes, webRoutes(site)...)493 routes = append(routes, webRoutes(site)...)
497 f.server = httpserver.New(httpserver.Config{494 f.server = httpserver.New(httpserver.Config{
498 Addr: cfg.Addr,495 Addr: cfg.Addr,
M internal/app/web.go +1 -0
@@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5858 Runs: f.CI,
5959 Artifacts: artifacts,
6060 Events: f.EventLog,
61+ Mirrors: f.Mirrors,
6162 Owner: cfg.Owner,
6263 PublicURL: cfg.PublicURL,
6364 SourceURL: cfg.SourceURL,
@@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
58 Runs: f.CI,58 Runs: f.CI,
59 Artifacts: artifacts,59 Artifacts: artifacts,
60 Events: f.EventLog,60 Events: f.EventLog,
61+ Mirrors: f.Mirrors,
61 Owner: cfg.Owner,62 Owner: cfg.Owner,
62 PublicURL: cfg.PublicURL,63 PublicURL: cfg.PublicURL,
63 SourceURL: cfg.SourceURL,64 SourceURL: cfg.SourceURL,

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 41

↑↓ moveenter openesc close