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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 … | |||
| 67 | 67 | ||
| 68 | 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 | 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. | |
| @@ -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> | |||
| 78 | 78 | ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only) | |
| 79 | 79 | ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only | |
| 80 | 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 | 83 | ermite web login # one-time sign-in link (60 s) | |
| 83 | 84 | ermite web logout --all # sign every browser out | |
| 84 | 85 | 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 only | 79 | 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 out | 84 | 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 | |||
| 37 | 37 | ||
| 38 | 38 | - The default branch and all tags; every branch with `--all-branches`. | |
| 39 | 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 | 43 | ## When it syncs | |
| 43 | 44 | ||
| 44 | 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 | 47 | - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes. | |
| 47 | 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 | 63 | ## See what happens | |
| 63 | 64 | ||
| 64 | 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 | 67 | - The repository page shows a badge to owners and admins. | |
| 67 | 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 | 70 | ## Security | |
| 70 | 71 | ||
| 71 | 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 | - | - `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. | |
| 74 | 82 | - 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 syncs | 43 | ## 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 happens | 63 | ## 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 | ## Security | 70 | ## 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 { | |||
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | 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 | 68 | if s.mirrors == nil { | |
| 69 | 69 | return "" | |
| 70 | 70 | } | |
| 71 | 71 | ctx, cancel := context.WithTimeout(ctx, 2*time.Second) | |
| 72 | 72 | defer cancel() | |
| 73 | 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 | 75 | select { | |
| 76 | 76 | case warning := <-result: | |
| 77 | 77 | if ctx.Err() != nil || warning == "" { | |
| @@ -256,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |||
| 256 | 256 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | |
| 257 | 257 | 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 | ||
| @@ -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 | return | 257 | 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) { | |||
| 420 | 420 | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |
| 421 | 421 | srv, sock := startHooks(t, hooks) | |
| 422 | 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 | 424 | srv.WithMirrorWarner(w) | |
| 425 | 425 | token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |
| 426 | 426 | defer release() | |
| @@ -442,7 +442,7 @@ func TestMirrorPostReceiveTimeout(t *testing.T) { | |||
| 442 | 442 | w := mocks.NewMirrorWarner(t) | |
| 443 | 443 | release := make(chan struct{}) | |
| 444 | 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 | 446 | deadline, ok := ctx.Deadline() | |
| 447 | 447 | assert.True(t, ok) | |
| 448 | 448 | 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 ( | |||
| 14 | 14 | "strings" | |
| 15 | 15 | "time" | |
| 16 | 16 | "unicode" | |
| 17 | + | "unicode/utf8" | |
| 17 | 18 | ||
| 18 | 19 | "bnema.dev/ermite/internal/domain" | |
| 19 | 20 | "bnema.dev/ermite/internal/ports" | |
| @@ -121,7 +122,7 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port | |||
| 121 | 122 | // -F /dev/null prevents ~/.ssh/config and system config from adding credentials | |
| 122 | 123 | // or weakening the required options. Required options precede caller extras. | |
| 123 | 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 | 126 | command += " -o " + quote(opt) | |
| 126 | 127 | } | |
| 127 | 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 | 170 | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"} | |
| 170 | 171 | } | |
| 171 | 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 | 176 | if rejected && class == domain.MirrorClassNone { | |
| 174 | 177 | class = domain.MirrorClassDiverged | |
| 175 | 178 | } | |
| @@ -186,6 +189,18 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port | |||
| 186 | 189 | 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 | 204 | func localGitError(stderr string) bool { | |
| 190 | 205 | for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} { | |
| 191 | 206 | if strings.Contains(strings.ToLower(stderr), marker) { | |
| @@ -195,6 +210,9 @@ func localGitError(stderr string) bool { | |||
| 195 | 210 | 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 | 216 | // safeDetail removes the complete target before taking a bounded stderr tail. | |
| 199 | 217 | // SSH diagnostics may repeat the user separately, so suppress user@ tokens too. | |
| 200 | 218 | func safeDetail(s, url string) string { | |
| @@ -206,10 +224,13 @@ func safeDetail(s, url string) string { | |||
| 206 | 224 | } | |
| 207 | 225 | } | |
| 208 | 226 | 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 | + | } | |
| 211 | 232 | } | |
| 212 | - | return s | |
| 233 | + | return strings.ToValidUTF8(s, "?") | |
| 213 | 234 | } | |
| 214 | 235 | ||
| 215 | 236 | 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 credentials | 122 | // -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.MirrorClassDiverged | 177 | class = domain.MirrorClassDiverged | ||
| 175 | } | 178 | } | ||
| @@ -186,6 +189,18 @@ func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result port | |||||
| 186 | return result, nil | 189 | 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 false | 210 | 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 s | 233 | + | 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 ( | |||
| 13 | 13 | "strings" | |
| 14 | 14 | "testing" | |
| 15 | 15 | "time" | |
| 16 | + | "unicode/utf8" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/stretchr/testify/require" | |
| 18 | 19 | ||
| @@ -103,7 +104,10 @@ func TestBoundsAndKeyEnvironment(t *testing.T) { | |||
| 103 | 104 | require.NoError(t, os.WriteFile(script, []byte(body), 0700)) | |
| 104 | 105 | cfg.Bin = script | |
| 105 | 106 | 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 | + | } | |
| 107 | 111 | t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded") | |
| 108 | 112 | ctx := context.Background() | |
| 109 | 113 | if mode == "cancel" { | |
| @@ -134,6 +138,9 @@ func TestBoundsAndKeyEnvironment(t *testing.T) { | |||
| 134 | 138 | require.Contains(t, string(env), "BatchMode=yes") | |
| 135 | 139 | require.Contains(t, string(env), "StrictHostKeyChecking=accept-new") | |
| 136 | 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 | 164 | require.NotContains(t, s, "password") | |
| 158 | 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 | + | } | |
| @@ -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 = script | 105 | cfg.Bin = script | ||
| 105 | cfg.MaxOutput = 1024 | 106 | 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 | + | } | |||
| 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 | |||
| 65 | 65 | _, err := i.db.Apply(ctx, &b) | |
| 66 | 66 | 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 | + | } | |
| @@ -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 err | 66 | 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 | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 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 | 55 | for range updateAttempts { | |
| 53 | 56 | var b kv.Batch | |
| 54 | 57 | if key != mirrorConfigKey { | |
| @@ -69,6 +72,16 @@ func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoNam | |||
| 69 | 72 | 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 | 85 | func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) { | |
| 73 | 86 | var pb storagepb.MirrorGlobal | |
| 74 | 87 | err := s.readMirror(ctx, mirrorConfigKey, &pb) | |
| @@ -124,6 +137,10 @@ func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.Mir | |||
| 124 | 137 | return decodeMirrorStatus(repo, &pb), err | |
| 125 | 138 | } | |
| 126 | 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 | 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 | 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 | 189 | if ifAbsent { | |
| 173 | 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 | 193 | _, err := s.db.Apply(ctx, &b) | |
| 177 | 194 | if ifAbsent && errors.Is(err, kv.ErrConflict) { | |
| 178 | 195 | return domain.ErrAlreadyExists | |
| @@ -182,9 +199,3 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i | |||
| 182 | 199 | } | |
| 183 | 200 | 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 | - | } | |
| @@ -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.Batch | 56 | 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.ErrConflict | 72 | 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.MirrorGlobal | 86 | 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), err | 137 | 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.ErrAlreadyExists | 195 | return domain.ErrAlreadyExists | ||
| @@ -182,9 +199,3 @@ func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, i | |||||
| 182 | } | 199 | } | ||
| 183 | return nil | 200 | 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 | |||
| 5 | 5 | import ( | |
| 6 | 6 | "bytes" | |
| 7 | 7 | "context" | |
| 8 | + | "strings" | |
| 8 | 9 | "testing" | |
| 10 | + | "unicode/utf8" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/stretchr/testify/require" | |
| 11 | 13 | ||
| @@ -96,3 +98,28 @@ func TestMirrorRoundTripsAndDelete(t *testing.T) { | |||
| 96 | 98 | _, err = s.GetStatus(ctx, repo) | |
| 97 | 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 | + | } | |
| @@ -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 { | |||
| 525 | 525 | out.Kind = jobEventToPB(ev) | |
| 526 | 526 | case domain.MirrorFailed: | |
| 527 | 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 | 529 | case domain.MirrorRecovered: | |
| 530 | 530 | out.Time = timestamp(ev.At) | |
| 531 | 531 | 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 | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | ||
| 9 | 10 | pb "bnema.dev/ermite-api/gen/ermite" | |
| @@ -27,16 +28,11 @@ func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb. | |||
| 27 | 28 | return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil | |
| 28 | 29 | } | |
| 29 | 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 | - | previous, err := s.mirrors.ShowKey(ctx, caller(ctx)) | |
| 31 | + | r, err := s.mirrors.RotateKey(ctx, caller(ctx)) | |
| 32 | 32 | if err != nil { | |
| 33 | 33 | return nil, connectError(ctx, err) | |
| 34 | 34 | } | |
| 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 | |
| 40 | 36 | } | |
| 41 | 37 | func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) { | |
| 42 | 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 | 125 | if err != nil { | |
| 130 | 126 | return nil, connectError(ctx, err) | |
| 131 | 127 | } | |
| 128 | + | result.Repo = name | |
| 132 | 129 | results = []ports.MirrorSyncResult{result} | |
| 133 | 130 | } | |
| 134 | 131 | out := make([]*pb.SyncMirrorResult, 0, len(results)) | |
| 135 | 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 | 134 | if err != nil { | |
| 138 | 135 | return nil, connectError(ctx, err) | |
| 139 | 136 | } | |
| 140 | 137 | if !result.Queued { | |
| 141 | 138 | 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 | 146 | 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 | 167 | func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey { | |
| 149 | 168 | return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)} | |
| 150 | 169 | } | |
| @@ -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)}), nil | 28 | 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.Status | 138 | 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}), nil | 146 | 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) { | |||
| 43 | 43 | require.NoError(t, err) | |
| 44 | 44 | status := domain.MirrorStatus{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "remote diverged", ConsecutiveFailures: 1} | |
| 45 | 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 | 47 | service.EXPECT().GetStatus(mock.Anything, domain.Principal{}, name).Return(ports.MirrorStatusView{Target: target, Status: status}, nil).Once() | |
| 48 | 48 | s := &mirrorService{service} | |
| 49 | 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 | 58 | name, err := domain.ParseRepoName("repo") | |
| 59 | 59 | require.NoError(t, err) | |
| 60 | 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 | 62 | require.NotNil(t, failed) | |
| 63 | 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 | 65 | recovered := watchEventToPB(ports.WatchEvent{Event: domain.MirrorRecovered{Repo: name, At: now}}) | |
| 66 | 66 | require.NotNil(t, recovered) | |
| 67 | 67 | assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo) | |
| @@ -69,4 +69,40 @@ func TestMirrorEvents(t *testing.T) { | |||
| 69 | 69 | fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields() | |
| 70 | 70 | assert.Nil(t, fields.ByName("url")) | |
| 71 | 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 | } | |
| @@ -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 ( | |||
| 6 | 6 | "context" | |
| 7 | 7 | ||
| 8 | 8 | "bnema.dev/ermite/internal/domain" | |
| 9 | - | "bnema.dev/ermite/internal/ports" | |
| 10 | 9 | ) | |
| 11 | 10 | ||
| 12 | 11 | // MirrorBadge is an administrative diagnostic in the summary rail. | |
| 13 | 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 | 14 | // mirrorBadge selects presentation only; the source authorizes the lookup and reason. | |
| 22 | 15 | // | |
| 23 | 16 | //hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator | |
| 24 | 17 | func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge { | |
| 25 | 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 | 20 | return nil | |
| 28 | 21 | } | |
| 29 | - | state, reason := s.mirrors.Badge(ctx, p, repo) | |
| 22 | + | state, reason := s.cfg.Mirrors.Badge(ctx, p, repo) | |
| 30 | 23 | if state != domain.MirrorFailing && state != domain.MirrorPaused { | |
| 31 | 24 | return nil | |
| 32 | 25 | } | |
| @@ -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 indicator | 16 | //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 nil | 20 | 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 nil | 24 | return nil | ||
| 32 | } | 25 | } | ||
M internal/adapters/web/mirror_test.go +1 -1
| @@ -35,7 +35,7 @@ func TestMirrorBadge(t *testing.T) { | |||
| 35 | 35 | if tc.lookup { | |
| 36 | 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 | 39 | badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo) | |
| 40 | 40 | out := renderRail(t, SummaryPage{Mirror: badge}) | |
| 41 | 41 | 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 | |||
| 47 | 47 | ||
| 48 | 48 | // Config configures a Server. | |
| 49 | 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 | 53 | // Repos and Browse are the use cases the pages are built from. | |
| 51 | 54 | Repos ports.RepoService | |
| 52 | 55 | Browse ports.BrowseService | |
| @@ -127,7 +130,6 @@ type Config struct { | |||
| 127 | 130 | ||
| 128 | 131 | // Server serves the pages. It is an http.Handler. | |
| 129 | 132 | type Server struct { | |
| 130 | - | mirrors ports.MirrorWarner | |
| 131 | 133 | cfg Config | |
| 132 | 134 | instance string | |
| 133 | 135 | 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.RepoService | 54 | Repos ports.RepoService | ||
| 52 | Browse ports.BrowseService | 55 | 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 Config | 133 | cfg Config | ||
| 132 | instance string | 134 | instance string | ||
| 133 | origin string // scheme://host of the public URL, what a POST must come from | 135 | 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 ( | |||
| 9 | 9 | ||
| 10 | 10 | "connectrpc.com/connect" | |
| 11 | 11 | "github.com/spf13/cobra" | |
| 12 | - | "google.golang.org/protobuf/encoding/protojson" | |
| 13 | - | "google.golang.org/protobuf/proto" | |
| 14 | 12 | ||
| 15 | 13 | "bnema.dev/ermite-api/gen/ermite" | |
| 16 | 14 | ) | |
| 17 | 15 | ||
| 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()), | |
| 23 | 79 | } | |
| 24 | - | _, err = fmt.Fprintln(cmd.OutOrStdout(), string(b)) | |
| 25 | - | return err | |
| 26 | 80 | } | |
| 27 | 81 | ||
| 28 | 82 | func newMirrorCmd(env *cliEnv) *cobra.Command { | |
| 29 | 83 | cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"} | |
| 30 | 84 | key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"} | |
| 31 | 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 | 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, | |
| 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} | |
| 38 | 93 | asJSON := addJSONFlag(sub) | |
| 39 | 94 | sub.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 40 | 95 | c, err := env.client(cmd) | |
| @@ -48,6 +103,9 @@ func newMirrorCmd(env *cliEnv) *cobra.Command { | |||
| 48 | 103 | return c.Describe(e) | |
| 49 | 104 | } | |
| 50 | 105 | k = res.Msg.GetKey() | |
| 106 | + | if prev := res.Msg.GetPreviousFingerprint(); prev != "" { | |
| 107 | + | noticef(cmd, "replaced key %s", clean(prev)) | |
| 108 | + | } | |
| 51 | 109 | noticef(cmd, "remotes refuse pushes until this key is added") | |
| 52 | 110 | } else { | |
| 53 | 111 | res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{})) | |
| @@ -57,7 +115,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command { | |||
| 57 | 115 | k = res.Msg.GetKey() | |
| 58 | 116 | } | |
| 59 | 117 | if *asJSON { | |
| 60 | - | return mirrorJSON(cmd, k) | |
| 118 | + | return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k)) | |
| 61 | 119 | } | |
| 62 | 120 | _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey())) | |
| 63 | 121 | return err | |
| @@ -91,7 +149,7 @@ func newMirrorCmd(env *cliEnv) *cobra.Command { | |||
| 91 | 149 | func newMirrorConfigCmd(env *cliEnv) *cobra.Command { | |
| 92 | 150 | var enabled, all bool | |
| 93 | 151 | 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 | 153 | cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy") | |
| 96 | 154 | cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}") | |
| 97 | 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 | 157 | asJSON := addJSONFlag(cmd) | |
| 100 | 158 | output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error { | |
| 101 | 159 | if json { | |
| 102 | - | return mirrorJSON(cmd, g) | |
| 160 | + | return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g)) | |
| 103 | 161 | } | |
| 104 | 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 | 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 | 192 | showJSON := addJSONFlag(show) | |
| 135 | 193 | show.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 136 | 194 | c, err := env.client(cmd) | |
| @@ -177,7 +235,7 @@ func mirrorStateName(s ermite.MirrorState) string { | |||
| 177 | 235 | } | |
| 178 | 236 | ||
| 179 | 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 | 239 | asJSON := addJSONFlag(cmd) | |
| 182 | 240 | cmd.RunE = func(cmd *cobra.Command, args []string) error { | |
| 183 | 241 | c, err := env.client(cmd) | |
| @@ -191,7 +249,7 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command { | |||
| 191 | 249 | return c.Describe(err) | |
| 192 | 250 | } | |
| 193 | 251 | if *asJSON { | |
| 194 | - | return mirrorJSON(cmd, res.Msg.GetStatus()) | |
| 252 | + | return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus())) | |
| 195 | 253 | } | |
| 196 | 254 | statuses = append(statuses, res.Msg.GetStatus()) | |
| 197 | 255 | } else { | |
| @@ -200,17 +258,11 @@ func newMirrorStatusCmd(env *cliEnv) *cobra.Command { | |||
| 200 | 258 | return c.Describe(err) | |
| 201 | 259 | } | |
| 202 | 260 | 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)) | |
| 211 | 264 | } | |
| 212 | - | _, err = fmt.Fprintln(cmd.OutOrStdout(), "["+strings.Join(items, ",")+"]") | |
| 213 | - | return err | |
| 265 | + | return writeJSON(cmd.OutOrStdout(), items) | |
| 214 | 266 | } | |
| 215 | 267 | statuses = res.Msg.GetStatuses() | |
| 216 | 268 | } | |
| @@ -246,11 +298,23 @@ func newMirrorSyncCmd(env *cliEnv) *cobra.Command { | |||
| 246 | 298 | if err != nil { | |
| 247 | 299 | return c.Describe(err) | |
| 248 | 300 | } | |
| 301 | + | failed := 0 | |
| 249 | 302 | rows := make([][]string, 0, len(res.Msg.GetResults())) | |
| 250 | 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 | 319 | return cmd | |
| 256 | 320 | } | |
| @@ -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 err | 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()), | |||
| 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 err | 121 | 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 bool | 150 | var enabled, all bool | ||
| 93 | var url, cron string | 151 | 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 cmd | 319 | return cmd | ||
| 256 | } | 320 | } | ||
M internal/app/cli_mirror_e2e_test.go +3 -2
| @@ -29,7 +29,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) { | |||
| 29 | 29 | var dataDir string | |
| 30 | 30 | h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) { | |
| 31 | 31 | o.mirrors = func(c *usecase.MirrorsConfig) { | |
| 32 | - | c.AllowAnyURL = true | |
| 32 | + | c.ValidateURL = func(string) error { return nil } // file:// remotes | |
| 33 | 33 | c.Clock = clk | |
| 34 | 34 | c.ScheduleTick = time.Hour | |
| 35 | 35 | c.Pusher = gitpush.New(gitpush.Config{ | |
| @@ -56,6 +56,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) { | |||
| 56 | 56 | assert.Equal(t, "ssh-ed25519", pub.Type()) | |
| 57 | 57 | assert.Equal(t, 1, strings.Count(key.stdout, "\n")) | |
| 58 | 58 | assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror") | |
| 59 | + | h.forge.Mirrors.Start() | |
| 59 | 60 | events := h.forge.Bus.Subscribe(h.ctx) | |
| 60 | 61 | wt := h.workTree("main", "README.md") | |
| 61 | 62 | h.mustGit(wt, "tag", "v1") | |
| @@ -88,7 +89,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) { | |||
| 88 | 89 | status := run("status", "proj").ok(t).stdout | |
| 89 | 90 | assert.Contains(t, status, "paused") | |
| 90 | 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 | 93 | h.mustGit(wt, "commit", "--allow-empty", "-m", "warning") | |
| 93 | 94 | warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main") | |
| 94 | 95 | assert.Contains(t, warning, "remote: ermite: mirror") | |
| @@ -29,7 +29,7 @@ func TestMirrorCLIEndToEnd(t *testing.T) { | |||||
| 29 | var dataDir string | 29 | 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 = true | 32 | + | c.ValidateURL = func(string) error { return nil } // file:// remotes |
| 33 | c.Clock = clk | 33 | c.Clock = clk | ||
| 34 | c.ScheduleTick = time.Hour | 34 | 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).stdout | 89 | 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) { | |||
| 44 | 44 | run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t) | |
| 45 | 45 | r = run("status", "proj", "--json").ok(t) | |
| 46 | 46 | 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"]) | |
| 50 | 51 | assert.Contains(t, run("status").ok(t).stdout, "FAILURES") | |
| 51 | 52 | assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1) | |
| 52 | 53 | run("remove", "proj").ok(t) | |
| 53 | 54 | run("exclude", "proj").ok(t) | |
| 54 | 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 | 57 | run("include", "proj").ok(t) | |
| 57 | 58 | first := run("key", "show").ok(t) | |
| 58 | 59 | assert.Empty(t, first.stderr) | |
| 60 | + | firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string) | |
| 59 | 61 | rotated := run("key", "rotate").ok(t) | |
| 62 | + | assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint) | |
| 60 | 63 | assert.NotEqual(t, first.stdout, rotated.stdout) | |
| 61 | 64 | assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added") | |
| 62 | 65 | 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 { | |||
| 31 | 31 | Comment *jsonComment `json:"comment,omitempty"` | |
| 32 | 32 | // Mergeability is set for a merge_request_mergeability event. | |
| 33 | 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 | 39 | type jsonRefUpdate struct { | |
| @@ -55,6 +58,8 @@ const ( | |||
| 55 | 58 | eventCommentDeleted = "comment_deleted" | |
| 56 | 59 | eventCommentResolved = "comment_resolved" | |
| 57 | 60 | eventMRMergeability = "merge_request_mergeability" | |
| 61 | + | eventMirrorFailed = "mirror_failed" | |
| 62 | + | eventMirrorRecovered = "mirror_recovered" | |
| 58 | 63 | ) | |
| 59 | 64 | ||
| 60 | 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 | 105 | case *ermite.Event_MergeRequestMergeability: | |
| 101 | 106 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | |
| 102 | 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 | 113 | case *ermite.Event_Gap: | |
| 104 | 114 | out.Type = eventGap | |
| 105 | 115 | default: | |
| @@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string { | |||
| 134 | 144 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 135 | 145 | case eventMRMergeability: | |
| 136 | 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 | 151 | case eventGap: | |
| 138 | 152 | detail = "events were lost, reload what you display" | |
| 139 | 153 | } | |
| @@ -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 = eventGap | 114 | 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) { | |||
| 266 | 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 | + | } | |
| @@ -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 | |||
| 490 | 490 | if err != nil { | |
| 491 | 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 | 493 | routes = append(routes, webRoutes(site)...) | |
| 497 | 494 | f.server = httpserver.New(httpserver.Config{ | |
| 498 | 495 | 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 | |||
| 58 | 58 | Runs: f.CI, | |
| 59 | 59 | Artifacts: artifacts, | |
| 60 | 60 | Events: f.EventLog, | |
| 61 | + | Mirrors: f.Mirrors, | |
| 61 | 62 | Owner: cfg.Owner, | |
| 62 | 63 | PublicURL: cfg.PublicURL, | |
| 63 | 64 | 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, | ||