detached 067ccd6

#15 feat(mirror): push mirrors to a second remote

state
merged · 067ccd6 · fast-forward
branch
feat/push-mirror → main
opened
bnema ·
updated

Push mirrors: keep a second remote (e.g. GitHub) in sync over SSH.

  • One server ed25519 key, sealed with ERMITE_SECRETS_KEY. Setup: ermite mirror key show | gh ssh-key add -
  • Global URL template ({repo}) with a cron, plus per-repo add/remove/exclude/include.
  • Pushes the default branch and tags (all branches optional). Never force, never --mirror, never deletes remote refs; a diverged remote pauses the mirror.
  • Anti-spam: no-op snapshot, single-flight per repo, 60 s manual throttle, cron at least every 5 min, backoff 5 min doubling to 6 h, pause after 5 transient failures or at once on diverged/auth/not found/host key.
  • Visibility: ermite mirror status, admin-only remote: warning on git push, repo page badge, mirror.failed (once per incident) and mirror.recovered events, ermite watch.
  • Pushes, merges and default-branch changes mark the repo dirty; the next due cron tick syncs it.
  • Requires ermite-api v0.12.0. Docs: docs/mirror.md.

Checks: go build, go vet, go test ./..., hexcheck -fail-on warn. Reviewed with two Go review rounds plus a targeted re-review.

CommitSubjectAuthorDateSignatureStatus
067ccd6fix(gitpush): log a failed removal of the temporary key directory and simplify the refspec checkbriceGPG Verified
0c0449bbuild(deps): require ermite-api v0.12.0briceGPG Verified
6793112fix(mirror): skip non-UTF-8 ref names, keep SyncAll errors to the failure class and document the Wait barrierbriceGPG Verified
1427755fix(mirror): sanitize push output, harden SSH options, add mirror events to watch and stable --json outputbriceGPG Verified
5805840fix(mirror): sync --all past failures, admin-only warnings, no config lock during pushes and deferred due reposbriceGPG Verified
5e1949ffix(domain): reject dash-led SSH users and hosts, check cron gaps on non-leap years and inject the URL validatorbriceGPG Verified
335d134fix(mirror): mark mirrors dirty on merges and default branch changes, validate config before saving and keep manual throttling on coalesced syncsbriceGPG Verified
c59b5fafix(domain): check the cron gap across the year, parse only on resolution and classify more SSH auth denialsbriceGPG Verified
4d330eadocs(mirror): document push mirror setup, sync rules, CLI and eventsbriceGPG Verified
49d746dmerge: push mirror CLI and composition into feat/push-mirrorbriceGPG Verified
86b5a04merge: push mirror warning and badge into feat/push-mirrorbriceGPG Verified
0ebe073feat(cli): add the mirror commands and wire push mirrors into the forgebriceGPG Verified
475ebb6feat(mirror): warn about failing mirrors on git push and show a badge on the repo pagebriceGPG Verified
ed419c7feat(rpc): serve the mirror service, convert mirror events and add the mirror clientbriceGPG Verified
26e2b66merge: push mirror use case into feat/push-mirrorbriceGPG Verified
3fb99b3merge: push mirror adapters into feat/push-mirrorbriceGPG Verified
a40b26ffeat(mirror): add the mirror use case with scheduler, single-flight sync and push triggerbriceGPG Verified
3aadd9bfix(domain): emit one mirror.failed event per incident, including the pausebriceGPG Verified
e3ee891feat(mirror): add the git push transport and the mirror KV storagebriceGPG Verified
1069a88feat(domain): add push mirror config, state machine, ref selection and portsbriceGPG Verified

Checks 8

69 files · +8558 -290
8/8
M README.md +1 -0
@@ -132,6 +132,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
132132 - [Configuration](docs/configuration.md): environment variables
133133 - [CLI](docs/cli.md): config file, commands, JSON output
134134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135+- [Push mirror](docs/mirror.md): push repositories to a remote over SSH
135136 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136137 - [Merge requests](docs/merge-requests.md)
137138 - [Issues](docs/issues.md)
@@ -132,6 +132,7 @@ Give the agent the [`skills/ermite`](skills/ermite/SKILL.md) skill so it knows t
132 - [Configuration](docs/configuration.md): environment variables132 - [Configuration](docs/configuration.md): environment variables
133 - [CLI](docs/cli.md): config file, commands, JSON output133 - [CLI](docs/cli.md): config file, commands, JSON output
134 - [Git over HTTP](docs/git-http.md): URLs and authentication134 - [Git over HTTP](docs/git-http.md): URLs and authentication
135+- [Push mirror](docs/mirror.md): push repositories to a remote over SSH
135 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens136 - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens
136 - [Merge requests](docs/merge-requests.md)137 - [Merge requests](docs/merge-requests.md)
137 - [Issues](docs/issues.md)138 - [Issues](docs/issues.md)
M docs/ci-events.md +9 -0
@@ -62,3 +62,12 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …
6262 - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`).
6363 - No filters; `[skip ci]` does not apply. A push of more than three tags starts none.
6464 - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`.
65+
66+## Mirror events
67+
68+Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident.
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`). 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`.
72+
73+The payload carries neither the remote URL, the reason nor the error detail; `ermite mirror status` shows the reason to repository admins.
@@ -62,3 +62,12 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value …
62 - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`).62 - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`).
63 - No filters; `[skip ci]` does not apply. A push of more than three tags starts none.63 - No filters; `[skip ci]` does not apply. A push of more than three tags starts none.
64 - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`.64 - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`.
65+
66+## Mirror events
67+
68+Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident.
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`). 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`.
72+
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 +8 -1
@@ -75,6 +75,11 @@ ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY
7575 ermite variable set|get|list|delete …
7676 ermite environment create|list|delete … --repo <repo>
7777
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
80+ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo>
81+ermite mirror status [repo] | sync <repo>|--all [--force] # sync --all: one row per repository, exit 1 if any failed
82+
7883 ermite web login # one-time sign-in link (60 s)
7984 ermite web logout --all # sign every browser out
8085 ermite web passkey list | remove <id>
@@ -87,9 +92,11 @@ ermite completion bash|zsh|fish
8792
8893 Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
8994
95+Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
96+
9097 ## Output
9198
92-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.
99+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
93100 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
94101 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
95102 - Errors are one line on stderr (`ermite: …`) with exit status 1.
@@ -75,6 +75,11 @@ ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY
75 ermite variable set|get|list|delete …75 ermite variable set|get|list|delete …
76 ermite environment create|list|delete … --repo <repo>76 ermite environment create|list|delete … --repo <repo>
77 77
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
80+ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo>
81+ermite mirror status [repo] | sync <repo>|--all [--force] # sync --all: one row per repository, exit 1 if any failed
82+
78 ermite web login # one-time sign-in link (60 s)83 ermite web login # one-time sign-in link (60 s)
79 ermite web logout --all # sign every browser out84 ermite web logout --all # sign every browser out
80 ermite web passkey list | remove <id>85 ermite web passkey list | remove <id>
@@ -87,9 +92,11 @@ ermite completion bash|zsh|fish
87 92
88 Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).93 Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md).
89 94
95+Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md).
96+
90 ## Output97 ## Output
91 98
92-- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`.99+- `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`.
93 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.100 - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`.
94 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.101 - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`.
95 - Errors are one line on stderr (`ermite: …`) with exit status 1.102 - Errors are one line on stderr (`ermite: …`) with exit status 1.
A docs/mirror.md +82 -0
@@ -0,0 +1,82 @@
1+# Push mirror
2+
3+Ermite can push its repositories to a remote Git host over SSH (GitHub, another forge…), to keep a read-only copy there. The push never forces and never deletes anything on the remote.
4+
5+## Set up
6+
7+1. The server needs `ERMITE_SECRETS_KEY`; it seals the mirror SSH key. Without it, syncs fail and nothing is stored.
8+2. Add the server's public mirror key to the remote account or repository, as a key allowed to push:
9+
10+ ```sh
11+ ermite mirror key show | gh ssh-key add - --title "ermite mirror"
12+ ```
13+
14+ `mirror key show` prints only the `authorized_keys` line. One key serves every mirror.
15+3. Set the global policy (owner only). The URL template holds `{repo}`, replaced by the repository name; the cron is five fields, in UTC:
16+
17+ ```sh
18+ ermite mirror config --url 'git@github.com:acme/{repo}.git' --cron '*/15 * * * *' --enabled
19+ ermite mirror config show
20+ ```
21+
22+ `config` changes only the flags you pass. `--all-branches` mirrors every branch instead of the default branch.
23+4. Adjust single repositories:
24+
25+ ```sh
26+ ermite mirror add <repo> <url> [--cron …] [--all-branches] # own URL (and schedule), even when the global policy is off
27+ ermite mirror exclude <repo> # leave the global policy
28+ ermite mirror include <repo> # back under it
29+ ermite mirror remove <repo> # drop the override
30+ ```
31+
32+ Without `--cron` or `--all-branches`, `add` inherits the global values.
33+
34+URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. The cron may not fire more often than every 5 minutes.
35+
36+## What is pushed
37+
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.
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`.
42+
43+## When it syncs
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.
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.
47+- `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
48+- Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
49+
50+## Limits against spam
51+
52+| Rule | Value |
53+|---|---|
54+| No-op | nothing changed since the last successful push: no connection |
55+| Manual sync | one per repository per 60 s, unless `--force` |
56+| Cron | at least 5 minutes between firings |
57+| Backoff after a transient failure | 5 min, doubling, up to 6 h |
58+| Paused | after 5 consecutive transient failures |
59+| Paused at once | diverged remote, key refused, remote not found, host key changed |
60+
61+A paused mirror is not retried by the schedule. Changing the mirror configuration of a repository clears its failures.
62+
63+## See what happens
64+
65+- `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures.
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.
67+- The repository page shows a badge to owners and admins.
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).
69+
70+## Security
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.
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.
82+- The scheduler runs in the server process: run a single ermite instance per data directory.
@@ -0,0 +1,82 @@
1+# Push mirror
2+
3+Ermite can push its repositories to a remote Git host over SSH (GitHub, another forge…), to keep a read-only copy there. The push never forces and never deletes anything on the remote.
4+
5+## Set up
6+
7+1. The server needs `ERMITE_SECRETS_KEY`; it seals the mirror SSH key. Without it, syncs fail and nothing is stored.
8+2. Add the server's public mirror key to the remote account or repository, as a key allowed to push:
9+
10+ ```sh
11+ ermite mirror key show | gh ssh-key add - --title "ermite mirror"
12+ ```
13+
14+ `mirror key show` prints only the `authorized_keys` line. One key serves every mirror.
15+3. Set the global policy (owner only). The URL template holds `{repo}`, replaced by the repository name; the cron is five fields, in UTC:
16+
17+ ```sh
18+ ermite mirror config --url 'git@github.com:acme/{repo}.git' --cron '*/15 * * * *' --enabled
19+ ermite mirror config show
20+ ```
21+
22+ `config` changes only the flags you pass. `--all-branches` mirrors every branch instead of the default branch.
23+4. Adjust single repositories:
24+
25+ ```sh
26+ ermite mirror add <repo> <url> [--cron …] [--all-branches] # own URL (and schedule), even when the global policy is off
27+ ermite mirror exclude <repo> # leave the global policy
28+ ermite mirror include <repo> # back under it
29+ ermite mirror remove <repo> # drop the override
30+ ```
31+
32+ Without `--cron` or `--all-branches`, `add` inherits the global values.
33+
34+URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. The cron may not fire more often than every 5 minutes.
35+
36+## What is pushed
37+
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.
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`.
42+
43+## When it syncs
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.
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.
47+- `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes.
48+- Overlapping syncs of a repository coalesce; only one push runs at a time per repository.
49+
50+## Limits against spam
51+
52+| Rule | Value |
53+|---|---|
54+| No-op | nothing changed since the last successful push: no connection |
55+| Manual sync | one per repository per 60 s, unless `--force` |
56+| Cron | at least 5 minutes between firings |
57+| Backoff after a transient failure | 5 min, doubling, up to 6 h |
58+| Paused | after 5 consecutive transient failures |
59+| Paused at once | diverged remote, key refused, remote not found, host key changed |
60+
61+A paused mirror is not retried by the schedule. Changing the mirror configuration of a repository clears its failures.
62+
63+## See what happens
64+
65+- `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures.
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.
67+- The repository page shows a badge to owners and admins.
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).
69+
70+## Security
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.
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.
82+- The scheduler runs in the server process: run a single ermite instance per data directory.
A gen/ermite/storage/mirror.pb.go +577 -0 generated file: collapsed
M go.mod +1 -1
@@ -13,7 +13,7 @@ tool (
1313
1414 require (
1515 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.11.0
16+ bnema.dev/ermite-api v0.12.0
1717 bnema.dev/ermite-git v0.1.0
1818 bnema.dev/ermite-kv v0.1.0
1919 connectrpc.com/connect v1.21.0
@@ -13,7 +13,7 @@ tool (
13 13
14 require (14 require (
15 bnema.dev/ermite-actions v0.2.015 bnema.dev/ermite-actions v0.2.0
16- bnema.dev/ermite-api v0.11.016+ bnema.dev/ermite-api v0.12.0
17 bnema.dev/ermite-git v0.1.017 bnema.dev/ermite-git v0.1.0
18 bnema.dev/ermite-kv v0.1.018 bnema.dev/ermite-kv v0.1.0
19 connectrpc.com/connect v1.21.019 connectrpc.com/connect v1.21.0
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
@@ -60,6 +60,11 @@ func transport() *http.Transport {
6060 // URL is the server address the client talks to.
6161 func (c *Client) URL() string { return c.cfg.URL }
6262
63+// Mirrors returns the MirrorService client.
64+func (c *Client) Mirrors() ermiteconnect.MirrorServiceClient {
65+ return ermiteconnect.NewMirrorServiceClient(c.http, c.base, c.opts...)
66+}
67+
6368 // Repos returns the RepoService client.
6469 func (c *Client) Repos() ermiteconnect.RepoServiceClient {
6570 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)
@@ -60,6 +60,11 @@ func transport() *http.Transport {
60 // URL is the server address the client talks to.60 // URL is the server address the client talks to.
61 func (c *Client) URL() string { return c.cfg.URL }61 func (c *Client) URL() string { return c.cfg.URL }
62 62
63+// Mirrors returns the MirrorService client.
64+func (c *Client) Mirrors() ermiteconnect.MirrorServiceClient {
65+ return ermiteconnect.NewMirrorServiceClient(c.http, c.base, c.opts...)
66+}
67+
63 // Repos returns the RepoService client.68 // Repos returns the RepoService client.
64 func (c *Client) Repos() ermiteconnect.RepoServiceClient {69 func (c *Client) Repos() ermiteconnect.RepoServiceClient {
65 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)70 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)
M internal/adapters/githttp/hooks.go +30 -3
@@ -37,8 +37,9 @@ type hookEntry struct {
3737 // HookServer registers the per-request hook tokens and serves the Unix
3838 // socket the hooks call back on. It implements HookTokens.
3939 type HookServer struct {
40- path string
41- hooks ports.PushHookService
40+ path string
41+ hooks ports.PushHookService
42+ mirrors ports.MirrorWarner
4243
4344 mu sync.RWMutex
4445 tokens map[string]hookEntry
@@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer {
5657 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
5758 }
5859
60+// WithMirrorWarner enables best-effort mirror warnings. Configure before serving.
61+func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
62+ s.mirrors = w
63+ return s
64+}
65+
66+// mirrorWarning bounds even a source that does not honor context cancellation.
67+func (s *HookServer) mirrorWarning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
68+ if s.mirrors == nil {
69+ return ""
70+ }
71+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
72+ defer cancel()
73+ result := make(chan string, 1)
74+ go func() { result <- s.mirrors.Warning(ctx, p, repo) }()
75+ select {
76+ case warning := <-result:
77+ if ctx.Err() != nil || warning == "" {
78+ return ""
79+ }
80+ return warning + "\n→ ermite mirror status " + repo.String()
81+ case <-ctx.Done():
82+ return ""
83+ }
84+}
85+
5986 // Socket returns the path of the socket.
6087 func (s *HookServer) Socket() string { return s.path }
6188
@@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
229256 s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"})
230257 return
231258 }
232- s.reply(ctx, conn, HookReply{OK: true})
259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.principal, entry.repo)})
233260 }
234261 }
235262
@@ -37,8 +37,9 @@ type hookEntry struct {
37 // HookServer registers the per-request hook tokens and serves the Unix37 // HookServer registers the per-request hook tokens and serves the Unix
38 // socket the hooks call back on. It implements HookTokens.38 // socket the hooks call back on. It implements HookTokens.
39 type HookServer struct {39 type HookServer struct {
40- path string40+ path string
41- hooks ports.PushHookService41+ hooks ports.PushHookService
42+ mirrors ports.MirrorWarner
42 43
43 mu sync.RWMutex44 mu sync.RWMutex
44 tokens map[string]hookEntry45 tokens map[string]hookEntry
@@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer {
56 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}57 return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})}
57 }58 }
58 59
60+// WithMirrorWarner enables best-effort mirror warnings. Configure before serving.
61+func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer {
62+ s.mirrors = w
63+ return s
64+}
65+
66+// mirrorWarning bounds even a source that does not honor context cancellation.
67+func (s *HookServer) mirrorWarning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
68+ if s.mirrors == nil {
69+ return ""
70+ }
71+ ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
72+ defer cancel()
73+ result := make(chan string, 1)
74+ go func() { result <- s.mirrors.Warning(ctx, p, repo) }()
75+ select {
76+ case warning := <-result:
77+ if ctx.Err() != nil || warning == "" {
78+ return ""
79+ }
80+ return warning + "\n→ ermite mirror status " + repo.String()
81+ case <-ctx.Done():
82+ return ""
83+ }
84+}
85+
59 // Socket returns the path of the socket.86 // Socket returns the path of the socket.
60 func (s *HookServer) Socket() string { return s.path }87 func (s *HookServer) Socket() string { return s.path }
61 88
@@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) {
229 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"})
230 return257 return
231 }258 }
232- s.reply(ctx, conn, HookReply{OK: true})259+ s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.principal, entry.repo)})
233 }260 }
234 }261 }
235 262
M internal/adapters/githttp/hooks_test.go +46 -0
@@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
412412 require.NoError(t, err)
413413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
414414 }
415+
416+func TestMirrorPostReceiveWarning(t *testing.T) {
417+ for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} {
418+ t.Run(warning, func(t *testing.T) {
419+ hooks := mocks.NewPushHookService(t)
420+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421+ srv, sock := startHooks(t, hooks)
422+ w := mocks.NewMirrorWarner(t)
423+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).Return(warning)
424+ srv.WithMirrorWarner(w)
425+ token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426+ defer release()
427+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
428+ assert.True(t, reply.OK)
429+ if warning == "" {
430+ assert.Empty(t, reply.Message)
431+ } else {
432+ assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message)
433+ }
434+ })
435+ }
436+}
437+
438+func TestMirrorPostReceiveTimeout(t *testing.T) {
439+ hooks := mocks.NewPushHookService(t)
440+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
441+ srv, sock := startHooks(t, hooks)
442+ w := mocks.NewMirrorWarner(t)
443+ release := make(chan struct{})
444+ defer close(release)
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()
447+ assert.True(t, ok)
448+ assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
449+ <-release // deliberately ignores cancellation
450+ return "late warning"
451+ })
452+ srv.WithMirrorWarner(w)
453+ token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
454+ defer revoke()
455+ start := time.Now()
456+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
457+ assert.True(t, reply.OK)
458+ assert.Empty(t, reply.Message)
459+ assert.Less(t, time.Since(start), 2500*time.Millisecond)
460+}
@@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) {
412 require.NoError(t, err)412 require.NoError(t, err)
413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")413 assert.Equal(t, "line\n", string(in), "stdin reaches the binary")
414 }414 }
415+
416+func TestMirrorPostReceiveWarning(t *testing.T) {
417+ for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} {
418+ t.Run(warning, func(t *testing.T) {
419+ hooks := mocks.NewPushHookService(t)
420+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
421+ srv, sock := startHooks(t, hooks)
422+ w := mocks.NewMirrorWarner(t)
423+ w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).Return(warning)
424+ srv.WithMirrorWarner(w)
425+ token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
426+ defer release()
427+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
428+ assert.True(t, reply.OK)
429+ if warning == "" {
430+ assert.Empty(t, reply.Message)
431+ } else {
432+ assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message)
433+ }
434+ })
435+ }
436+}
437+
438+func TestMirrorPostReceiveTimeout(t *testing.T) {
439+ hooks := mocks.NewPushHookService(t)
440+ hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
441+ srv, sock := startHooks(t, hooks)
442+ w := mocks.NewMirrorWarner(t)
443+ release := make(chan struct{})
444+ defer close(release)
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()
447+ assert.True(t, ok)
448+ assert.LessOrEqual(t, time.Until(deadline), 2*time.Second)
449+ <-release // deliberately ignores cancellation
450+ return "late warning"
451+ })
452+ srv.WithMirrorWarner(w)
453+ token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root())
454+ defer revoke()
455+ start := time.Now()
456+ reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`)
457+ assert.True(t, reply.OK)
458+ assert.Empty(t, reply.Message)
459+ assert.Less(t, time.Since(start), 2500*time.Millisecond)
460+}
A internal/adapters/gitpush/gitpush.go +258 -0
@@ -0,0 +1,258 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package gitpush implements bounded, non-forcing mirror pushes over git/ssh.
4+package gitpush
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/ed25519"
10+ "encoding/pem"
11+ "errors"
12+ "os"
13+ "os/exec"
14+ "strings"
15+ "time"
16+ "unicode"
17+ "unicode/utf8"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "github.com/bnema/zerowrap"
22+ "golang.org/x/crypto/ssh"
23+)
24+
25+type Config struct {
26+ Bin, Path, Home, KnownHosts, TempDir string
27+ Timeout time.Duration
28+ MaxOutput int
29+ AllowedProtocols string
30+ SSHOptions []string
31+ RepoDir func(domain.RepoName) string
32+}
33+type Pusher struct{ cfg Config }
34+
35+var _ ports.MirrorPusher = (*Pusher)(nil)
36+
37+func New(cfg Config) *Pusher {
38+ if cfg.Timeout <= 0 {
39+ cfg.Timeout = 10 * time.Minute
40+ }
41+ if cfg.MaxOutput <= 0 {
42+ cfg.MaxOutput = 1 << 20
43+ }
44+ if cfg.AllowedProtocols == "" {
45+ cfg.AllowedProtocols = "ssh"
46+ }
47+ cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...)
48+ return &Pusher{cfg: cfg}
49+}
50+func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
51+
52+// validRefspec deliberately accepts only explicit same-name heads and tags.
53+// Empty sources, wildcards, force prefixes and revision expressions never reach git.
54+func validRefspec(s string) bool {
55+ a, b, ok := strings.Cut(s, ":")
56+ if !ok || a != b || !strings.HasPrefix(a, "refs/heads/") && !strings.HasPrefix(a, "refs/tags/") {
57+ return false
58+ }
59+ if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") {
60+ return false
61+ }
62+ for _, part := range strings.Split(a, "/") {
63+ if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
64+ return false
65+ }
66+ }
67+ return !strings.ContainsFunc(a, unicode.IsControl)
68+}
69+
70+func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) {
71+ start := time.Now()
72+ defer func() { result.Duration = time.Since(start) }()
73+ internal := func(detail string) error {
74+ return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
75+ }
76+ if err := parent.Err(); err != nil {
77+ return result, err
78+ }
79+ if len(req.PrivateKey) != ed25519.PrivateKeySize {
80+ return result, internal("invalid mirror private key")
81+ }
82+ if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" {
83+ return result, internal("mirror transport directories are not configured")
84+ }
85+ if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) {
86+ return result, internal("invalid mirror target")
87+ }
88+ for _, ref := range req.Refspecs {
89+ if !validRefspec(ref) {
90+ return result, internal("invalid mirror refspec")
91+ }
92+ }
93+ if len(req.Refspecs) == 0 {
94+ return result, nil
95+ } // never let git select implicit refspecs
96+ ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout)
97+ defer cancel()
98+ if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil {
99+ return result, internal("create mirror key directory")
100+ }
101+ if err := os.Chmod(p.cfg.TempDir, 0700); err != nil {
102+ return result, internal("protect mirror key directory")
103+ }
104+ dir, e := os.MkdirTemp(p.cfg.TempDir, "push-")
105+ if e != nil {
106+ return result, internal("create temporary mirror directory")
107+ }
108+ defer func() {
109+ // The directory holds the private key: a leftover is reported, and resetRunDir wipes it at
110+ // the next start.
111+ if e := os.RemoveAll(dir); e != nil {
112+ log := zerowrap.FromCtx(parent)
113+ log.Warn().Err(e).Msg("remove the temporary mirror key directory")
114+ }
115+ }()
116+ block, e := ssh.MarshalPrivateKey(req.PrivateKey, "")
117+ if e != nil {
118+ return result, internal("encode mirror private key")
119+ }
120+ f, e := os.CreateTemp(dir, "key-")
121+ if e != nil {
122+ return result, internal("create temporary mirror key")
123+ }
124+ keyPath := f.Name()
125+ _, e = f.Write(pem.EncodeToMemory(block))
126+ closeErr := f.Close()
127+ if e != nil || closeErr != nil {
128+ return result, internal("write temporary mirror key")
129+ }
130+ // -F /dev/null prevents ~/.ssh/config and system config from adding credentials
131+ // or weakening the required options. Required options precede caller extras.
132+ command := "ssh -F /dev/null -i " + quote(keyPath)
133+ 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...) {
134+ command += " -o " + quote(opt)
135+ }
136+ cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)
137+ runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond))
138+ defer stop()
139+ stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
140+ stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
141+ args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL}
142+ args = append(args, req.Refspecs...)
143+ cmd := exec.Command(p.cfg.Bin, args...)
144+ cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"}
145+ cmd.Dir = p.cfg.Home
146+ cmd.Stdout = stdout
147+ cmd.Stderr = stderr
148+ cmd.WaitDelay = cleanup
149+ runErr := run(runCtx, cmd)
150+ if parent.Err() != nil {
151+ return result, parent.Err()
152+ }
153+ rejected := false
154+ for line := range strings.SplitSeq(stdout.buf.String(), "\n") {
155+ fields := strings.Split(line, "\t")
156+ if len(fields) < 3 || len(fields[0]) != 1 {
157+ continue
158+ }
159+ var status ports.MirrorRefStatus
160+ switch fields[0] {
161+ case " ", "*":
162+ status = ports.MirrorRefOK
163+ case "=":
164+ status = ports.MirrorRefUpToDate
165+ case "!":
166+ status = ports.MirrorRefRejected
167+ rejected = true
168+ default:
169+ continue
170+ }
171+ _, ref, _ := strings.Cut(fields[1], ":")
172+ result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)})
173+ }
174+ if stdout.exceeded || stderr.exceeded {
175+ return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"}
176+ }
177+ if runCtx.Err() != nil {
178+ return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}
179+ }
180+ if runErr != nil || rejected {
181+ // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting
182+ // message must not pick the failure class. They stay in the detail.
183+ class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false)
184+ if rejected && class == domain.MirrorClassNone {
185+ class = domain.MirrorClassDiverged
186+ }
187+ var execErr *exec.Error
188+ if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) {
189+ class = domain.MirrorClassInternal
190+ }
191+ detail := safeDetail(stderr.buf.String(), req.URL)
192+ if detail == "" {
193+ detail = "git push failed"
194+ }
195+ return result, &domain.MirrorError{Class: class, Detail: detail}
196+ }
197+ return result, nil
198+}
199+
200+// withoutRemoteLines drops the lines the remote sent through git ("remote: ...").
201+func withoutRemoteLines(s string) string {
202+ lines := strings.Split(s, "\n")
203+ kept := lines[:0]
204+ for _, l := range lines {
205+ if !strings.HasPrefix(strings.TrimSpace(l), "remote:") {
206+ kept = append(kept, l)
207+ }
208+ }
209+ return strings.Join(kept, "\n")
210+}
211+
212+func localGitError(stderr string) bool {
213+ for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {
214+ if strings.Contains(strings.ToLower(stderr), marker) {
215+ return true
216+ }
217+ }
218+ return false
219+}
220+
221+// maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired.
222+const maxDetail = 4096
223+
224+// safeDetail removes the complete target before taking a bounded stderr tail.
225+// SSH diagnostics may repeat the user separately, so suppress user@ tokens too.
226+func safeDetail(s, url string) string {
227+ s = strings.ReplaceAll(s, url, "[mirror target]")
228+ words := strings.Fields(s)
229+ for i, w := range words {
230+ if strings.Contains(w, "@") || strings.Contains(w, "://") {
231+ words[i] = "[redacted]"
232+ }
233+ }
234+ s = strings.Join(words, " ")
235+ if len(s) > maxDetail {
236+ s = s[len(s)-maxDetail:]
237+ for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character
238+ s = s[1:]
239+ }
240+ }
241+ return strings.ToValidUTF8(s, "?")
242+}
243+
244+type limitWriter struct {
245+ buf bytes.Buffer
246+ max int
247+ exceeded bool
248+ cancel context.CancelFunc
249+}
250+
251+func (w *limitWriter) Write(p []byte) (int, error) {
252+ if len(p) > w.max-w.buf.Len() {
253+ w.exceeded = true
254+ w.cancel()
255+ return 0, errors.New("output limit reached")
256+ }
257+ return w.buf.Write(p)
258+}
@@ -0,0 +1,258 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package gitpush implements bounded, non-forcing mirror pushes over git/ssh.
4+package gitpush
5+
6+import (
7+ "bytes"
8+ "context"
9+ "crypto/ed25519"
10+ "encoding/pem"
11+ "errors"
12+ "os"
13+ "os/exec"
14+ "strings"
15+ "time"
16+ "unicode"
17+ "unicode/utf8"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+ "github.com/bnema/zerowrap"
22+ "golang.org/x/crypto/ssh"
23+)
24+
25+type Config struct {
26+ Bin, Path, Home, KnownHosts, TempDir string
27+ Timeout time.Duration
28+ MaxOutput int
29+ AllowedProtocols string
30+ SSHOptions []string
31+ RepoDir func(domain.RepoName) string
32+}
33+type Pusher struct{ cfg Config }
34+
35+var _ ports.MirrorPusher = (*Pusher)(nil)
36+
37+func New(cfg Config) *Pusher {
38+ if cfg.Timeout <= 0 {
39+ cfg.Timeout = 10 * time.Minute
40+ }
41+ if cfg.MaxOutput <= 0 {
42+ cfg.MaxOutput = 1 << 20
43+ }
44+ if cfg.AllowedProtocols == "" {
45+ cfg.AllowedProtocols = "ssh"
46+ }
47+ cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...)
48+ return &Pusher{cfg: cfg}
49+}
50+func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" }
51+
52+// validRefspec deliberately accepts only explicit same-name heads and tags.
53+// Empty sources, wildcards, force prefixes and revision expressions never reach git.
54+func validRefspec(s string) bool {
55+ a, b, ok := strings.Cut(s, ":")
56+ if !ok || a != b || !strings.HasPrefix(a, "refs/heads/") && !strings.HasPrefix(a, "refs/tags/") {
57+ return false
58+ }
59+ if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") {
60+ return false
61+ }
62+ for _, part := range strings.Split(a, "/") {
63+ if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
64+ return false
65+ }
66+ }
67+ return !strings.ContainsFunc(a, unicode.IsControl)
68+}
69+
70+func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) {
71+ start := time.Now()
72+ defer func() { result.Duration = time.Since(start) }()
73+ internal := func(detail string) error {
74+ return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
75+ }
76+ if err := parent.Err(); err != nil {
77+ return result, err
78+ }
79+ if len(req.PrivateKey) != ed25519.PrivateKeySize {
80+ return result, internal("invalid mirror private key")
81+ }
82+ if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" {
83+ return result, internal("mirror transport directories are not configured")
84+ }
85+ if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) {
86+ return result, internal("invalid mirror target")
87+ }
88+ for _, ref := range req.Refspecs {
89+ if !validRefspec(ref) {
90+ return result, internal("invalid mirror refspec")
91+ }
92+ }
93+ if len(req.Refspecs) == 0 {
94+ return result, nil
95+ } // never let git select implicit refspecs
96+ ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout)
97+ defer cancel()
98+ if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil {
99+ return result, internal("create mirror key directory")
100+ }
101+ if err := os.Chmod(p.cfg.TempDir, 0700); err != nil {
102+ return result, internal("protect mirror key directory")
103+ }
104+ dir, e := os.MkdirTemp(p.cfg.TempDir, "push-")
105+ if e != nil {
106+ return result, internal("create temporary mirror directory")
107+ }
108+ defer func() {
109+ // The directory holds the private key: a leftover is reported, and resetRunDir wipes it at
110+ // the next start.
111+ if e := os.RemoveAll(dir); e != nil {
112+ log := zerowrap.FromCtx(parent)
113+ log.Warn().Err(e).Msg("remove the temporary mirror key directory")
114+ }
115+ }()
116+ block, e := ssh.MarshalPrivateKey(req.PrivateKey, "")
117+ if e != nil {
118+ return result, internal("encode mirror private key")
119+ }
120+ f, e := os.CreateTemp(dir, "key-")
121+ if e != nil {
122+ return result, internal("create temporary mirror key")
123+ }
124+ keyPath := f.Name()
125+ _, e = f.Write(pem.EncodeToMemory(block))
126+ closeErr := f.Close()
127+ if e != nil || closeErr != nil {
128+ return result, internal("write temporary mirror key")
129+ }
130+ // -F /dev/null prevents ~/.ssh/config and system config from adding credentials
131+ // or weakening the required options. Required options precede caller extras.
132+ command := "ssh -F /dev/null -i " + quote(keyPath)
133+ 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...) {
134+ command += " -o " + quote(opt)
135+ }
136+ cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond)
137+ runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond))
138+ defer stop()
139+ stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
140+ stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop}
141+ args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL}
142+ args = append(args, req.Refspecs...)
143+ cmd := exec.Command(p.cfg.Bin, args...)
144+ cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"}
145+ cmd.Dir = p.cfg.Home
146+ cmd.Stdout = stdout
147+ cmd.Stderr = stderr
148+ cmd.WaitDelay = cleanup
149+ runErr := run(runCtx, cmd)
150+ if parent.Err() != nil {
151+ return result, parent.Err()
152+ }
153+ rejected := false
154+ for line := range strings.SplitSeq(stdout.buf.String(), "\n") {
155+ fields := strings.Split(line, "\t")
156+ if len(fields) < 3 || len(fields[0]) != 1 {
157+ continue
158+ }
159+ var status ports.MirrorRefStatus
160+ switch fields[0] {
161+ case " ", "*":
162+ status = ports.MirrorRefOK
163+ case "=":
164+ status = ports.MirrorRefUpToDate
165+ case "!":
166+ status = ports.MirrorRefRejected
167+ rejected = true
168+ default:
169+ continue
170+ }
171+ _, ref, _ := strings.Cut(fields[1], ":")
172+ result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)})
173+ }
174+ if stdout.exceeded || stderr.exceeded {
175+ return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"}
176+ }
177+ if runCtx.Err() != nil {
178+ return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"}
179+ }
180+ if runErr != nil || rejected {
181+ // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting
182+ // message must not pick the failure class. They stay in the detail.
183+ class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false)
184+ if rejected && class == domain.MirrorClassNone {
185+ class = domain.MirrorClassDiverged
186+ }
187+ var execErr *exec.Error
188+ if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) {
189+ class = domain.MirrorClassInternal
190+ }
191+ detail := safeDetail(stderr.buf.String(), req.URL)
192+ if detail == "" {
193+ detail = "git push failed"
194+ }
195+ return result, &domain.MirrorError{Class: class, Detail: detail}
196+ }
197+ return result, nil
198+}
199+
200+// withoutRemoteLines drops the lines the remote sent through git ("remote: ...").
201+func withoutRemoteLines(s string) string {
202+ lines := strings.Split(s, "\n")
203+ kept := lines[:0]
204+ for _, l := range lines {
205+ if !strings.HasPrefix(strings.TrimSpace(l), "remote:") {
206+ kept = append(kept, l)
207+ }
208+ }
209+ return strings.Join(kept, "\n")
210+}
211+
212+func localGitError(stderr string) bool {
213+ for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} {
214+ if strings.Contains(strings.ToLower(stderr), marker) {
215+ return true
216+ }
217+ }
218+ return false
219+}
220+
221+// maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired.
222+const maxDetail = 4096
223+
224+// safeDetail removes the complete target before taking a bounded stderr tail.
225+// SSH diagnostics may repeat the user separately, so suppress user@ tokens too.
226+func safeDetail(s, url string) string {
227+ s = strings.ReplaceAll(s, url, "[mirror target]")
228+ words := strings.Fields(s)
229+ for i, w := range words {
230+ if strings.Contains(w, "@") || strings.Contains(w, "://") {
231+ words[i] = "[redacted]"
232+ }
233+ }
234+ s = strings.Join(words, " ")
235+ if len(s) > maxDetail {
236+ s = s[len(s)-maxDetail:]
237+ for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character
238+ s = s[1:]
239+ }
240+ }
241+ return strings.ToValidUTF8(s, "?")
242+}
243+
244+type limitWriter struct {
245+ buf bytes.Buffer
246+ max int
247+ exceeded bool
248+ cancel context.CancelFunc
249+}
250+
251+func (w *limitWriter) Write(p []byte) (int, error) {
252+ if len(p) > w.max-w.buf.Len() {
253+ w.exceeded = true
254+ w.cancel()
255+ return 0, errors.New("output limit reached")
256+ }
257+ return w.buf.Write(p)
258+}
A internal/adapters/gitpush/gitpush_test.go +197 -0
@@ -0,0 +1,197 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitpush
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "crypto/rand"
9+ "errors"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "testing"
15+ "time"
16+ "unicode/utf8"
17+
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+)
23+
24+func git(t *testing.T, args ...string) string {
25+ t.Helper()
26+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27+ defer cancel()
28+ cmd := exec.CommandContext(ctx, "git", args...)
29+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"}
30+ out, err := cmd.CombinedOutput()
31+ require.NoError(t, err, string(out))
32+ return strings.TrimSpace(string(out))
33+}
34+func setup(t *testing.T) (Config, ports.MirrorPush) {
35+ t.Helper()
36+ root := t.TempDir()
37+ source := filepath.Join(root, "source")
38+ git(t, "init", "-b", "main", source)
39+ git(t, "-C", source, "commit", "--allow-empty", "-m", "first")
40+ bin, err := exec.LookPath("git")
41+ require.NoError(t, err)
42+ _, key, err := ed25519.GenerateKey(rand.Reader)
43+ require.NoError(t, err)
44+ return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}}
45+}
46+func clean(t *testing.T, cfg Config) {
47+ t.Helper()
48+ entries, err := os.ReadDir(cfg.TempDir)
49+ require.NoError(t, err)
50+ require.Empty(t, entries)
51+}
52+func TestPushLocal(t *testing.T) {
53+ cfg, req := setup(t)
54+ remote := filepath.Join(t.TempDir(), "remote.git")
55+ git(t, "init", "--bare", remote)
56+ req.URL = "file://" + remote
57+ p := New(cfg)
58+ result, err := p.Push(context.Background(), req)
59+ require.NoError(t, err)
60+ require.Len(t, result.Refs, 1)
61+ require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status)
62+ clean(t, cfg)
63+ result, err = p.Push(context.Background(), req)
64+ require.NoError(t, err)
65+ require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status)
66+ clean(t, cfg)
67+ other := filepath.Join(t.TempDir(), "other")
68+ git(t, "init", "-b", "main", other)
69+ git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated")
70+ git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other")
71+ // Move remote main locally, without a force push, to establish divergence.
72+ otherID := git(t, "-C", other, "rev-parse", "HEAD")
73+ git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID)
74+ _, err = p.Push(context.Background(), req)
75+ var me *domain.MirrorError
76+ require.ErrorAs(t, err, &me)
77+ require.Equal(t, domain.MirrorClassDiverged, me.Class)
78+ require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main"))
79+ clean(t, cfg)
80+ req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git")
81+ _, err = p.Push(context.Background(), req)
82+ require.ErrorAs(t, err, &me)
83+ require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class)
84+ clean(t, cfg)
85+}
86+func TestBoundsAndKeyEnvironment(t *testing.T) {
87+ for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} {
88+ t.Run(mode, func(t *testing.T) {
89+ cfg, req := setup(t)
90+ req.URL = "file:///unused"
91+ script := filepath.Join(t.TempDir(), "git")
92+ report := filepath.Join(t.TempDir(), "env")
93+ body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n"
94+ switch mode {
95+ case "error":
96+ body += "echo 'Permission denied (publickey)' >&2; exit 1\n"
97+ case "timeout", "cancel":
98+ body += "sleep 10\n"
99+ case "stdout":
100+ body += "head -c 4096 /dev/zero\n"
101+ case "stderr":
102+ body += "head -c 4096 /dev/zero >&2\n"
103+ }
104+ require.NoError(t, os.WriteFile(script, []byte(body), 0700))
105+ cfg.Bin = script
106+ cfg.MaxOutput = 1024
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+ }
111+ t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
112+ ctx := context.Background()
113+ if mode == "cancel" {
114+ var cancel context.CancelFunc
115+ ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
116+ defer cancel()
117+ }
118+ _, err := New(cfg).Push(ctx, req)
119+ switch mode {
120+ case "success":
121+ require.NoError(t, err)
122+ case "cancel":
123+ require.ErrorIs(t, err, context.DeadlineExceeded)
124+ default:
125+ var me *domain.MirrorError
126+ require.ErrorAs(t, err, &me)
127+ want := domain.MirrorClassTransient
128+ if mode == "error" {
129+ want = domain.MirrorClassAuthDenied
130+ }
131+ require.Equal(t, want, me.Class)
132+ }
133+ clean(t, cfg)
134+ env, e := os.ReadFile(report)
135+ require.NoError(t, e)
136+ require.NotContains(t, string(env), "ERMITE_")
137+ require.Contains(t, string(env), "IdentitiesOnly=yes")
138+ require.Contains(t, string(env), "BatchMode=yes")
139+ require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
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")
144+ })
145+ }
146+}
147+func TestClassifierSamples(t *testing.T) {
148+ for _, tc := range []struct {
149+ s string
150+ c domain.MirrorFailureClass
151+ }{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} {
152+ require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false))
153+ }
154+}
155+func TestRejectUnsafeRefspecs(t *testing.T) {
156+ for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} {
157+ require.False(t, validRefspec(ref))
158+ }
159+ require.True(t, validRefspec("refs/tags/v1:refs/tags/v1"))
160+}
161+func TestSafeDetail(t *testing.T) {
162+ s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo")
163+ require.LessOrEqual(t, len(s), 4096)
164+ require.NotContains(t, s, "password")
165+ require.NotContains(t, s, "user@")
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+}
@@ -0,0 +1,197 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitpush
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "crypto/rand"
9+ "errors"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "testing"
15+ "time"
16+ "unicode/utf8"
17+
18+ "github.com/stretchr/testify/require"
19+
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+)
23+
24+func git(t *testing.T, args ...string) string {
25+ t.Helper()
26+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
27+ defer cancel()
28+ cmd := exec.CommandContext(ctx, "git", args...)
29+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.org", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.org"}
30+ out, err := cmd.CombinedOutput()
31+ require.NoError(t, err, string(out))
32+ return strings.TrimSpace(string(out))
33+}
34+func setup(t *testing.T) (Config, ports.MirrorPush) {
35+ t.Helper()
36+ root := t.TempDir()
37+ source := filepath.Join(root, "source")
38+ git(t, "init", "-b", "main", source)
39+ git(t, "-C", source, "commit", "--allow-empty", "-m", "first")
40+ bin, err := exec.LookPath("git")
41+ require.NoError(t, err)
42+ _, key, err := ed25519.GenerateKey(rand.Reader)
43+ require.NoError(t, err)
44+ return Config{Bin: bin, Path: os.Getenv("PATH"), Home: t.TempDir(), KnownHosts: filepath.Join(root, "mirror_known_hosts"), TempDir: filepath.Join(root, "keys"), RepoDir: func(domain.RepoName) string { return source }, AllowedProtocols: "file", Timeout: 5 * time.Second}, ports.MirrorPush{PrivateKey: key, Refspecs: []string{"refs/heads/main:refs/heads/main"}}
45+}
46+func clean(t *testing.T, cfg Config) {
47+ t.Helper()
48+ entries, err := os.ReadDir(cfg.TempDir)
49+ require.NoError(t, err)
50+ require.Empty(t, entries)
51+}
52+func TestPushLocal(t *testing.T) {
53+ cfg, req := setup(t)
54+ remote := filepath.Join(t.TempDir(), "remote.git")
55+ git(t, "init", "--bare", remote)
56+ req.URL = "file://" + remote
57+ p := New(cfg)
58+ result, err := p.Push(context.Background(), req)
59+ require.NoError(t, err)
60+ require.Len(t, result.Refs, 1)
61+ require.Equal(t, ports.MirrorRefOK, result.Refs[0].Status)
62+ clean(t, cfg)
63+ result, err = p.Push(context.Background(), req)
64+ require.NoError(t, err)
65+ require.Equal(t, ports.MirrorRefUpToDate, result.Refs[0].Status)
66+ clean(t, cfg)
67+ other := filepath.Join(t.TempDir(), "other")
68+ git(t, "init", "-b", "main", other)
69+ git(t, "-C", other, "commit", "--allow-empty", "-m", "unrelated")
70+ git(t, "-C", other, "push", "file://"+remote, "refs/heads/main:refs/heads/other")
71+ // Move remote main locally, without a force push, to establish divergence.
72+ otherID := git(t, "-C", other, "rev-parse", "HEAD")
73+ git(t, "--git-dir", remote, "update-ref", "refs/heads/main", otherID)
74+ _, err = p.Push(context.Background(), req)
75+ var me *domain.MirrorError
76+ require.ErrorAs(t, err, &me)
77+ require.Equal(t, domain.MirrorClassDiverged, me.Class)
78+ require.Equal(t, otherID, git(t, "--git-dir", remote, "rev-parse", "refs/heads/main"))
79+ clean(t, cfg)
80+ req.URL = "file://" + filepath.Join(t.TempDir(), "missing.git")
81+ _, err = p.Push(context.Background(), req)
82+ require.ErrorAs(t, err, &me)
83+ require.Equal(t, domain.MirrorClassRemoteNotFound, me.Class)
84+ clean(t, cfg)
85+}
86+func TestBoundsAndKeyEnvironment(t *testing.T) {
87+ for _, mode := range []string{"success", "error", "timeout", "stdout", "stderr", "cancel"} {
88+ t.Run(mode, func(t *testing.T) {
89+ cfg, req := setup(t)
90+ req.URL = "file:///unused"
91+ script := filepath.Join(t.TempDir(), "git")
92+ report := filepath.Join(t.TempDir(), "env")
93+ body := "#!/bin/sh\nenv > '" + report + "'\nfor d in '" + cfg.TempDir + "'/push-*; do\n test \"$(stat -c %a \"$d\")\" = 700 || exit 90\n for k in \"$d\"/key-*; do test \"$(stat -c %a \"$k\")\" = 600 || exit 91; done\ndone\n"
94+ switch mode {
95+ case "error":
96+ body += "echo 'Permission denied (publickey)' >&2; exit 1\n"
97+ case "timeout", "cancel":
98+ body += "sleep 10\n"
99+ case "stdout":
100+ body += "head -c 4096 /dev/zero\n"
101+ case "stderr":
102+ body += "head -c 4096 /dev/zero >&2\n"
103+ }
104+ require.NoError(t, os.WriteFile(script, []byte(body), 0700))
105+ cfg.Bin = script
106+ cfg.MaxOutput = 1024
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+ }
111+ t.Setenv("ERMITE_PRIVATE_TEST", "not-forwarded")
112+ ctx := context.Background()
113+ if mode == "cancel" {
114+ var cancel context.CancelFunc
115+ ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
116+ defer cancel()
117+ }
118+ _, err := New(cfg).Push(ctx, req)
119+ switch mode {
120+ case "success":
121+ require.NoError(t, err)
122+ case "cancel":
123+ require.ErrorIs(t, err, context.DeadlineExceeded)
124+ default:
125+ var me *domain.MirrorError
126+ require.ErrorAs(t, err, &me)
127+ want := domain.MirrorClassTransient
128+ if mode == "error" {
129+ want = domain.MirrorClassAuthDenied
130+ }
131+ require.Equal(t, want, me.Class)
132+ }
133+ clean(t, cfg)
134+ env, e := os.ReadFile(report)
135+ require.NoError(t, e)
136+ require.NotContains(t, string(env), "ERMITE_")
137+ require.Contains(t, string(env), "IdentitiesOnly=yes")
138+ require.Contains(t, string(env), "BatchMode=yes")
139+ require.Contains(t, string(env), "StrictHostKeyChecking=accept-new")
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")
144+ })
145+ }
146+}
147+func TestClassifierSamples(t *testing.T) {
148+ for _, tc := range []struct {
149+ s string
150+ c domain.MirrorFailureClass
151+ }{{"Permission denied (publickey)", domain.MirrorClassAuthDenied}, {"Repository not found", domain.MirrorClassRemoteNotFound}, {"Host key verification failed", domain.MirrorClassHostKey}, {"Could not resolve hostname", domain.MirrorClassTransient}, {"non-fast-forward", domain.MirrorClassDiverged}} {
152+ require.Equal(t, tc.c, domain.ClassifyMirrorOutput(tc.s, errors.New("exit 1"), false))
153+ }
154+}
155+func TestRejectUnsafeRefspecs(t *testing.T) {
156+ for _, ref := range []string{"+refs/heads/main:refs/heads/main", ":refs/heads/main", "--mirror", "refs/heads/*:refs/heads/*", "refs/heads/main:refs/heads/other"} {
157+ require.False(t, validRefspec(ref))
158+ }
159+ require.True(t, validRefspec("refs/tags/v1:refs/tags/v1"))
160+}
161+func TestSafeDetail(t *testing.T) {
162+ s := safeDetail(strings.Repeat("x", 5000)+" ssh://user:password@host/repo user@host", "ssh://user:password@host/repo")
163+ require.LessOrEqual(t, len(s), 4096)
164+ require.NotContains(t, s, "password")
165+ require.NotContains(t, s, "user@")
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_linux.go +107 -0
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os/exec"
12+ "syscall"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Tests replace these two.
18+var (
19+ waitid = unix.Waitid
20+ killGroup = func(pgid int) error { return syscall.Kill(-pgid, syscall.SIGKILL) }
21+)
22+
23+// run starts cmd, waits for it, and kills what it leaves running.
24+//
25+// git runs git-remote-https, which is the process that connects and that holds
26+// the pipes of git's output. Killing git alone leaves it running, and Wait
27+// blocked on the pipes. So git leads a process group of its own, and the group
28+// is killed when ctx is done and also when git has exited.
29+//
30+// The invariant that makes killing a group safe: the group is only signalled
31+// while its leader is still not reaped. Linux does not reuse a PID as long as
32+// the process is a zombie nobody waited for, nor while it is the group ID of a
33+// live member, so -pid is still our group and cannot be another one. The
34+// leader is therefore waited for with waitid(WNOWAIT), which observes its exit
35+// without reaping it; the group is signalled; and only then cmd.Wait reaps. A
36+// kill(-pid) after Wait, or from Cmd.Cancel which races with it, would not be
37+// safe. When waitid fails, whatever the reason, nothing proves the leader is
38+// unreaped: the group is not signalled, only the leader is killed (through
39+// its pidfd, which cannot hit a reused PID), and the error is returned.
40+// cmd.WaitDelay (set by the caller) stays the backstop for a descendant that
41+// escaped the group and holds the pipes.
42+func run(ctx context.Context, cmd *exec.Cmd) error {
43+ if err := ctx.Err(); err != nil {
44+ return err
45+ }
46+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
47+ if err := cmd.Start(); err != nil {
48+ return err
49+ }
50+ pid := cmd.Process.Pid
51+ observed := make(chan error, 1)
52+ go func() { observed <- waitExit(pid) }()
53+
54+ var werr error
55+ select {
56+ case err := <-observed:
57+ if err != nil {
58+ werr = err // the leader may have been reaped: no group signal
59+ } else {
60+ signalGroup(cmd, pid) // exited, not reaped: a zombie
61+ }
62+ case <-ctx.Done():
63+ select {
64+ case err := <-observed:
65+ // waitid answered at the same time: trust it as above.
66+ if err != nil {
67+ werr = err
68+ } else {
69+ signalGroup(cmd, pid)
70+ }
71+ default:
72+ // No waitid error so far: the leader is alive or a zombie, unreaped
73+ // (only cmd.Wait below reaps it).
74+ signalGroup(cmd, pid)
75+ if err := <-observed; err != nil {
76+ werr = err // after the signals: reported, nothing more to do
77+ }
78+ }
79+ }
80+ if werr != nil {
81+ _ = cmd.Process.Kill()
82+ }
83+ err := cmd.Wait()
84+ if werr != nil {
85+ return fmt.Errorf("waiting for the process %d: %w", pid, werr)
86+ }
87+ return err
88+}
89+
90+// signalGroup kills the group of the leader, and the leader alone should it
91+// have left the group. The leader must not be reaped yet.
92+func signalGroup(cmd *exec.Cmd, pid int) {
93+ _ = killGroup(pid)
94+ _ = cmd.Process.Kill()
95+}
96+
97+// waitExit returns when the child pid has exited, without reaping it, or the
98+// error of waitid.
99+func waitExit(pid int) error {
100+ var info unix.Siginfo
101+ for {
102+ err := waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil)
103+ if !errors.Is(err, unix.EINTR) {
104+ return err
105+ }
106+ }
107+}
@@ -0,0 +1,107 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "errors"
10+ "fmt"
11+ "os/exec"
12+ "syscall"
13+
14+ "golang.org/x/sys/unix"
15+)
16+
17+// Tests replace these two.
18+var (
19+ waitid = unix.Waitid
20+ killGroup = func(pgid int) error { return syscall.Kill(-pgid, syscall.SIGKILL) }
21+)
22+
23+// run starts cmd, waits for it, and kills what it leaves running.
24+//
25+// git runs git-remote-https, which is the process that connects and that holds
26+// the pipes of git's output. Killing git alone leaves it running, and Wait
27+// blocked on the pipes. So git leads a process group of its own, and the group
28+// is killed when ctx is done and also when git has exited.
29+//
30+// The invariant that makes killing a group safe: the group is only signalled
31+// while its leader is still not reaped. Linux does not reuse a PID as long as
32+// the process is a zombie nobody waited for, nor while it is the group ID of a
33+// live member, so -pid is still our group and cannot be another one. The
34+// leader is therefore waited for with waitid(WNOWAIT), which observes its exit
35+// without reaping it; the group is signalled; and only then cmd.Wait reaps. A
36+// kill(-pid) after Wait, or from Cmd.Cancel which races with it, would not be
37+// safe. When waitid fails, whatever the reason, nothing proves the leader is
38+// unreaped: the group is not signalled, only the leader is killed (through
39+// its pidfd, which cannot hit a reused PID), and the error is returned.
40+// cmd.WaitDelay (set by the caller) stays the backstop for a descendant that
41+// escaped the group and holds the pipes.
42+func run(ctx context.Context, cmd *exec.Cmd) error {
43+ if err := ctx.Err(); err != nil {
44+ return err
45+ }
46+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
47+ if err := cmd.Start(); err != nil {
48+ return err
49+ }
50+ pid := cmd.Process.Pid
51+ observed := make(chan error, 1)
52+ go func() { observed <- waitExit(pid) }()
53+
54+ var werr error
55+ select {
56+ case err := <-observed:
57+ if err != nil {
58+ werr = err // the leader may have been reaped: no group signal
59+ } else {
60+ signalGroup(cmd, pid) // exited, not reaped: a zombie
61+ }
62+ case <-ctx.Done():
63+ select {
64+ case err := <-observed:
65+ // waitid answered at the same time: trust it as above.
66+ if err != nil {
67+ werr = err
68+ } else {
69+ signalGroup(cmd, pid)
70+ }
71+ default:
72+ // No waitid error so far: the leader is alive or a zombie, unreaped
73+ // (only cmd.Wait below reaps it).
74+ signalGroup(cmd, pid)
75+ if err := <-observed; err != nil {
76+ werr = err // after the signals: reported, nothing more to do
77+ }
78+ }
79+ }
80+ if werr != nil {
81+ _ = cmd.Process.Kill()
82+ }
83+ err := cmd.Wait()
84+ if werr != nil {
85+ return fmt.Errorf("waiting for the process %d: %w", pid, werr)
86+ }
87+ return err
88+}
89+
90+// signalGroup kills the group of the leader, and the leader alone should it
91+// have left the group. The leader must not be reaped yet.
92+func signalGroup(cmd *exec.Cmd, pid int) {
93+ _ = killGroup(pid)
94+ _ = cmd.Process.Kill()
95+}
96+
97+// waitExit returns when the child pid has exited, without reaping it, or the
98+// error of waitid.
99+func waitExit(pid int) error {
100+ var info unix.Siginfo
101+ for {
102+ err := waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil)
103+ if !errors.Is(err, unix.EINTR) {
104+ return err
105+ }
106+ }
107+}
A internal/adapters/gitpush/run_other.go +33 -0
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// run starts cmd and waits for it, killing the process (not its group: only
13+// Linux has the guarantee that makes killing a group safe, see run_linux.go)
14+// when ctx is done. cmd.WaitDelay stays the backstop for helpers that hold the
15+// pipes.
16+func run(ctx context.Context, cmd *exec.Cmd) error {
17+ if err := ctx.Err(); err != nil {
18+ return err
19+ }
20+ if err := cmd.Start(); err != nil {
21+ return err
22+ }
23+ done := make(chan struct{})
24+ defer close(done)
25+ go func() {
26+ select {
27+ case <-ctx.Done():
28+ _ = cmd.Process.Kill()
29+ case <-done:
30+ }
31+ }()
32+ return cmd.Wait()
33+}
@@ -0,0 +1,33 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !linux
4+
5+package gitpush
6+
7+import (
8+ "context"
9+ "os/exec"
10+)
11+
12+// run starts cmd and waits for it, killing the process (not its group: only
13+// Linux has the guarantee that makes killing a group safe, see run_linux.go)
14+// when ctx is done. cmd.WaitDelay stays the backstop for helpers that hold the
15+// pipes.
16+func run(ctx context.Context, cmd *exec.Cmd) error {
17+ if err := ctx.Err(); err != nil {
18+ return err
19+ }
20+ if err := cmd.Start(); err != nil {
21+ return err
22+ }
23+ done := make(chan struct{})
24+ defer close(done)
25+ go func() {
26+ select {
27+ case <-ctx.Done():
28+ _ = cmd.Process.Kill()
29+ case <-done:
30+ }
31+ }()
32+ return cmd.Wait()
33+}
M internal/adapters/kvstore/export_test.go +6 -0
@@ -65,3 +65,9 @@ func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n
6565 _, err := i.db.Apply(ctx, &b)
6666 return err
6767 }
68+
69+// RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests.
70+func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
71+ item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
72+ return item.Value, ok
73+}
@@ -65,3 +65,9 @@ func (i *Issues) PutRawIssueForTest(ctx context.Context, repo domain.RepoName, n
65 _, err := i.db.Apply(ctx, &b)65 _, err := i.db.Apply(ctx, &b)
66 return err66 return err
67 }67 }
68+
69+// RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests.
70+func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) {
71+ item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath))
72+ return item.Value, ok
73+}
A internal/adapters/kvstore/mirror.go +201 -0
@@ -0,0 +1,201 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ kv "bnema.dev/ermite-kv"
13+ "google.golang.org/protobuf/proto"
14+
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+var _ ports.MirrorStore = (*Store)(nil)
21+var _ ports.MirrorKeyStore = (*Store)(nil)
22+
23+const mirrorConfigKey = "mirror/config"
24+const mirrorRepoPrefix = "mirror/repo/"
25+const mirrorStatePrefix = "mirror/state/"
26+const mirrorSnapshotPrefix = "mirror/snapshot/"
27+
28+func mirrorKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
29+ var keys [][]byte
30+ for _, prefix := range []string{mirrorRepoPrefix, mirrorStatePrefix, mirrorSnapshotPrefix} {
31+ key := []byte(prefix + repo.String())
32+ if _, ok := snap.Get(ctx, key); ok {
33+ keys = append(keys, key)
34+ }
35+ }
36+ return keys
37+}
38+
39+func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) error {
40+ item, ok := s.db.Get(ctx, []byte(key))
41+ if !ok {
42+ return domain.ErrNotFound
43+ }
44+ if err := proto.Unmarshal(item.Value, pb); err != nil {
45+ return fmt.Errorf("decode mirror record: %w", err)
46+ }
47+ return nil
48+}
49+
50+func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error {
51+ val, err := marshalMirror(pb)
52+ if err != nil {
53+ return err
54+ }
55+ for range updateAttempts {
56+ var b kv.Batch
57+ if key != mirrorConfigKey {
58+ if err := guardRepoKey(ctx, s.db, &b, repo); err != nil {
59+ return err
60+ }
61+ }
62+ b.Put([]byte(key), val)
63+ _, err := s.db.Apply(ctx, &b)
64+ if errors.Is(err, kv.ErrConflict) {
65+ continue
66+ }
67+ if err != nil {
68+ return fmt.Errorf("store mirror record: %w", err)
69+ }
70+ return nil
71+ }
72+ return domain.ErrConflict
73+}
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+
85+func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {
86+ var pb storagepb.MirrorGlobal
87+ err := s.readMirror(ctx, mirrorConfigKey, &pb)
88+ if errors.Is(err, domain.ErrNotFound) {
89+ err = nil
90+ }
91+ return domain.MirrorGlobalConfig{Enabled: pb.Enabled, URLTemplate: pb.UrlTemplate, Cron: pb.Cron, AllBranches: pb.AllBranches, UpdatedAt: decodeTime(pb.UpdatedAt)}, err
92+}
93+func (s *Store) PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error {
94+ return s.writeMirror(ctx, mirrorConfigKey, domain.RepoName{}, &storagepb.MirrorGlobal{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: encodeTime(g.UpdatedAt)})
95+}
96+func decodeMirrorRepo(repo domain.RepoName, pb *storagepb.MirrorRepo) domain.MirrorRepoConfig {
97+ return domain.MirrorRepoConfig{Repo: repo, URL: pb.Url, Cron: pb.Cron, AllBranches: pb.AllBranches, Excluded: pb.Excluded, UpdatedAt: decodeTime(pb.UpdatedAt)}
98+}
99+func (s *Store) GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) {
100+ var pb storagepb.MirrorRepo
101+ err := s.readMirror(ctx, mirrorRepoPrefix+repo.String(), &pb)
102+ return decodeMirrorRepo(repo, &pb), err
103+}
104+func (s *Store) PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error {
105+ return s.writeMirror(ctx, mirrorRepoPrefix+c.Repo.String(), c.Repo, &storagepb.MirrorRepo{Url: c.URL, Cron: c.Cron, AllBranches: c.AllBranches, Excluded: c.Excluded, UpdatedAt: encodeTime(c.UpdatedAt)})
106+}
107+func (s *Store) DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error {
108+ var b kv.Batch
109+ b.Delete([]byte(mirrorRepoPrefix + repo.String()))
110+ _, err := s.db.Apply(ctx, &b)
111+ if err != nil {
112+ return fmt.Errorf("delete mirror config: %w", err)
113+ }
114+ return nil
115+}
116+func (s *Store) ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) {
117+ var out []domain.MirrorRepoConfig
118+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorRepoPrefix)) {
119+ repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorRepoPrefix))
120+ if err != nil {
121+ return nil, err
122+ }
123+ var pb storagepb.MirrorRepo
124+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
125+ return nil, fmt.Errorf("decode mirror config: %w", err)
126+ }
127+ out = append(out, decodeMirrorRepo(repo, &pb))
128+ }
129+ return out, nil
130+}
131+func decodeMirrorStatus(repo domain.RepoName, pb *storagepb.MirrorState) domain.MirrorStatus {
132+ return domain.MirrorStatus{Repo: repo, State: domain.MirrorState(pb.State), Class: domain.MirrorFailureClass(pb.Class), Reason: pb.Reason, Detail: pb.Detail, ConsecutiveFailures: int(pb.ConsecutiveFailures), LastAttemptAt: decodeTime(pb.LastAttemptAt), LastSuccessAt: decodeTime(pb.LastSuccessAt), NextRetryAt: decodeTime(pb.NextRetryAt), PausedAt: decodeTime(pb.PausedAt), UpdatedAt: decodeTime(pb.UpdatedAt)}
133+}
134+func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) {
135+ var pb storagepb.MirrorState
136+ err := s.readMirror(ctx, mirrorStatePrefix+repo.String(), &pb)
137+ return decodeMirrorStatus(repo, &pb), err
138+}
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, "?")
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)})
145+}
146+func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {
147+ var out []domain.MirrorStatus
148+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorStatePrefix)) {
149+ repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorStatePrefix))
150+ if err != nil {
151+ return nil, err
152+ }
153+ var pb storagepb.MirrorState
154+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
155+ return nil, fmt.Errorf("decode mirror state: %w", err)
156+ }
157+ out = append(out, decodeMirrorStatus(repo, &pb))
158+ }
159+ return out, nil
160+}
161+func (s *Store) GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) {
162+ var pb storagepb.MirrorSnapshot
163+ err := s.readMirror(ctx, mirrorSnapshotPrefix+repo.String(), &pb)
164+ out := domain.MirrorSnapshot{URL: pb.Url, AllBranches: pb.AllBranches, At: decodeTime(pb.At)}
165+ for _, ref := range pb.Refs {
166+ id, parseErr := domain.ParseObjectID(ref.Id)
167+ if parseErr != nil {
168+ return domain.MirrorSnapshot{}, fmt.Errorf("decode mirror snapshot ref: %w", parseErr)
169+ }
170+ out.Refs = append(out.Refs, domain.MirrorRef{Name: ref.Name, ID: id})
171+ }
172+ return out, err
173+}
174+func (s *Store) PutSnapshot(ctx context.Context, repo domain.RepoName, c domain.MirrorSnapshot) error {
175+ pb := &storagepb.MirrorSnapshot{Url: c.URL, AllBranches: c.AllBranches, At: encodeTime(c.At)}
176+ for _, ref := range c.Refs {
177+ pb.Refs = append(pb.Refs, &storagepb.MirrorSnapshotRef{Name: ref.Name, Id: ref.ID.String()})
178+ }
179+ return s.writeMirror(ctx, mirrorSnapshotPrefix+repo.String(), repo, pb)
180+}
181+func (s *Store) GetMirrorKey(ctx context.Context) ([]byte, time.Time, error) {
182+ var pb storagepb.MirrorKey
183+ err := s.readMirror(ctx, domain.MirrorKeyPath, &pb)
184+ return pb.Sealed, decodeTime(pb.CreatedAt), err
185+}
186+func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, ifAbsent bool) error {
187+ var b kv.Batch
188+ key := []byte(domain.MirrorKeyPath)
189+ if ifAbsent {
190+ b.RequireAbsent(key)
191+ }
192+ b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) // bytes and a timestamp: always encodable
193+ _, err := s.db.Apply(ctx, &b)
194+ if ifAbsent && errors.Is(err, kv.ErrConflict) {
195+ return domain.ErrAlreadyExists
196+ }
197+ if err != nil {
198+ return fmt.Errorf("store mirror key: %w", err)
199+ }
200+ return nil
201+}
@@ -0,0 +1,201 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ kv "bnema.dev/ermite-kv"
13+ "google.golang.org/protobuf/proto"
14+
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+var _ ports.MirrorStore = (*Store)(nil)
21+var _ ports.MirrorKeyStore = (*Store)(nil)
22+
23+const mirrorConfigKey = "mirror/config"
24+const mirrorRepoPrefix = "mirror/repo/"
25+const mirrorStatePrefix = "mirror/state/"
26+const mirrorSnapshotPrefix = "mirror/snapshot/"
27+
28+func mirrorKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
29+ var keys [][]byte
30+ for _, prefix := range []string{mirrorRepoPrefix, mirrorStatePrefix, mirrorSnapshotPrefix} {
31+ key := []byte(prefix + repo.String())
32+ if _, ok := snap.Get(ctx, key); ok {
33+ keys = append(keys, key)
34+ }
35+ }
36+ return keys
37+}
38+
39+func (s *Store) readMirror(ctx context.Context, key string, pb proto.Message) error {
40+ item, ok := s.db.Get(ctx, []byte(key))
41+ if !ok {
42+ return domain.ErrNotFound
43+ }
44+ if err := proto.Unmarshal(item.Value, pb); err != nil {
45+ return fmt.Errorf("decode mirror record: %w", err)
46+ }
47+ return nil
48+}
49+
50+func (s *Store) writeMirror(ctx context.Context, key string, repo domain.RepoName, pb proto.Message) error {
51+ val, err := marshalMirror(pb)
52+ if err != nil {
53+ return err
54+ }
55+ for range updateAttempts {
56+ var b kv.Batch
57+ if key != mirrorConfigKey {
58+ if err := guardRepoKey(ctx, s.db, &b, repo); err != nil {
59+ return err
60+ }
61+ }
62+ b.Put([]byte(key), val)
63+ _, err := s.db.Apply(ctx, &b)
64+ if errors.Is(err, kv.ErrConflict) {
65+ continue
66+ }
67+ if err != nil {
68+ return fmt.Errorf("store mirror record: %w", err)
69+ }
70+ return nil
71+ }
72+ return domain.ErrConflict
73+}
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+
85+func (s *Store) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {
86+ var pb storagepb.MirrorGlobal
87+ err := s.readMirror(ctx, mirrorConfigKey, &pb)
88+ if errors.Is(err, domain.ErrNotFound) {
89+ err = nil
90+ }
91+ return domain.MirrorGlobalConfig{Enabled: pb.Enabled, URLTemplate: pb.UrlTemplate, Cron: pb.Cron, AllBranches: pb.AllBranches, UpdatedAt: decodeTime(pb.UpdatedAt)}, err
92+}
93+func (s *Store) PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error {
94+ return s.writeMirror(ctx, mirrorConfigKey, domain.RepoName{}, &storagepb.MirrorGlobal{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: encodeTime(g.UpdatedAt)})
95+}
96+func decodeMirrorRepo(repo domain.RepoName, pb *storagepb.MirrorRepo) domain.MirrorRepoConfig {
97+ return domain.MirrorRepoConfig{Repo: repo, URL: pb.Url, Cron: pb.Cron, AllBranches: pb.AllBranches, Excluded: pb.Excluded, UpdatedAt: decodeTime(pb.UpdatedAt)}
98+}
99+func (s *Store) GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) {
100+ var pb storagepb.MirrorRepo
101+ err := s.readMirror(ctx, mirrorRepoPrefix+repo.String(), &pb)
102+ return decodeMirrorRepo(repo, &pb), err
103+}
104+func (s *Store) PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error {
105+ return s.writeMirror(ctx, mirrorRepoPrefix+c.Repo.String(), c.Repo, &storagepb.MirrorRepo{Url: c.URL, Cron: c.Cron, AllBranches: c.AllBranches, Excluded: c.Excluded, UpdatedAt: encodeTime(c.UpdatedAt)})
106+}
107+func (s *Store) DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error {
108+ var b kv.Batch
109+ b.Delete([]byte(mirrorRepoPrefix + repo.String()))
110+ _, err := s.db.Apply(ctx, &b)
111+ if err != nil {
112+ return fmt.Errorf("delete mirror config: %w", err)
113+ }
114+ return nil
115+}
116+func (s *Store) ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) {
117+ var out []domain.MirrorRepoConfig
118+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorRepoPrefix)) {
119+ repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorRepoPrefix))
120+ if err != nil {
121+ return nil, err
122+ }
123+ var pb storagepb.MirrorRepo
124+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
125+ return nil, fmt.Errorf("decode mirror config: %w", err)
126+ }
127+ out = append(out, decodeMirrorRepo(repo, &pb))
128+ }
129+ return out, nil
130+}
131+func decodeMirrorStatus(repo domain.RepoName, pb *storagepb.MirrorState) domain.MirrorStatus {
132+ return domain.MirrorStatus{Repo: repo, State: domain.MirrorState(pb.State), Class: domain.MirrorFailureClass(pb.Class), Reason: pb.Reason, Detail: pb.Detail, ConsecutiveFailures: int(pb.ConsecutiveFailures), LastAttemptAt: decodeTime(pb.LastAttemptAt), LastSuccessAt: decodeTime(pb.LastSuccessAt), NextRetryAt: decodeTime(pb.NextRetryAt), PausedAt: decodeTime(pb.PausedAt), UpdatedAt: decodeTime(pb.UpdatedAt)}
133+}
134+func (s *Store) GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) {
135+ var pb storagepb.MirrorState
136+ err := s.readMirror(ctx, mirrorStatePrefix+repo.String(), &pb)
137+ return decodeMirrorStatus(repo, &pb), err
138+}
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, "?")
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)})
145+}
146+func (s *Store) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {
147+ var out []domain.MirrorStatus
148+ for key, item := range s.db.Snapshot(ctx).Prefix(ctx, []byte(mirrorStatePrefix)) {
149+ repo, err := domain.ParseRepoName(strings.TrimPrefix(string(key), mirrorStatePrefix))
150+ if err != nil {
151+ return nil, err
152+ }
153+ var pb storagepb.MirrorState
154+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
155+ return nil, fmt.Errorf("decode mirror state: %w", err)
156+ }
157+ out = append(out, decodeMirrorStatus(repo, &pb))
158+ }
159+ return out, nil
160+}
161+func (s *Store) GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) {
162+ var pb storagepb.MirrorSnapshot
163+ err := s.readMirror(ctx, mirrorSnapshotPrefix+repo.String(), &pb)
164+ out := domain.MirrorSnapshot{URL: pb.Url, AllBranches: pb.AllBranches, At: decodeTime(pb.At)}
165+ for _, ref := range pb.Refs {
166+ id, parseErr := domain.ParseObjectID(ref.Id)
167+ if parseErr != nil {
168+ return domain.MirrorSnapshot{}, fmt.Errorf("decode mirror snapshot ref: %w", parseErr)
169+ }
170+ out.Refs = append(out.Refs, domain.MirrorRef{Name: ref.Name, ID: id})
171+ }
172+ return out, err
173+}
174+func (s *Store) PutSnapshot(ctx context.Context, repo domain.RepoName, c domain.MirrorSnapshot) error {
175+ pb := &storagepb.MirrorSnapshot{Url: c.URL, AllBranches: c.AllBranches, At: encodeTime(c.At)}
176+ for _, ref := range c.Refs {
177+ pb.Refs = append(pb.Refs, &storagepb.MirrorSnapshotRef{Name: ref.Name, Id: ref.ID.String()})
178+ }
179+ return s.writeMirror(ctx, mirrorSnapshotPrefix+repo.String(), repo, pb)
180+}
181+func (s *Store) GetMirrorKey(ctx context.Context) ([]byte, time.Time, error) {
182+ var pb storagepb.MirrorKey
183+ err := s.readMirror(ctx, domain.MirrorKeyPath, &pb)
184+ return pb.Sealed, decodeTime(pb.CreatedAt), err
185+}
186+func (s *Store) PutMirrorKey(ctx context.Context, sealed []byte, at time.Time, ifAbsent bool) error {
187+ var b kv.Batch
188+ key := []byte(domain.MirrorKeyPath)
189+ if ifAbsent {
190+ b.RequireAbsent(key)
191+ }
192+ b.Put(key, mustMarshal(&storagepb.MirrorKey{Sealed: sealed, CreatedAt: encodeTime(at)})) // bytes and a timestamp: always encodable
193+ _, err := s.db.Apply(ctx, &b)
194+ if ifAbsent && errors.Is(err, kv.ErrConflict) {
195+ return domain.ErrAlreadyExists
196+ }
197+ if err != nil {
198+ return fmt.Errorf("store mirror key: %w", err)
199+ }
200+ return nil
201+}
A internal/adapters/kvstore/mirror_test.go +125 -0
@@ -0,0 +1,125 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "strings"
9+ "testing"
10+ "unicode/utf8"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func TestMirrorRoundTripsAndDelete(t *testing.T) {
18+ ctx := context.Background()
19+ s := openStore(t, t.TempDir())
20+ repo := name(t, "mirror")
21+ g, err := s.GetGlobal(ctx)
22+ require.NoError(t, err)
23+ require.Equal(t, domain.MirrorGlobalConfig{}, g)
24+ _, err = s.GetRepoConfig(ctx, repo)
25+ require.ErrorIs(t, err, domain.ErrNotFound)
26+ _, err = s.GetStatus(ctx, repo)
27+ require.ErrorIs(t, err, domain.ErrNotFound)
28+ _, err = s.GetSnapshot(ctx, repo)
29+ require.ErrorIs(t, err, domain.ErrNotFound)
30+ _, _, err = s.GetMirrorKey(ctx)
31+ require.ErrorIs(t, err, domain.ErrNotFound)
32+ require.NoError(t, s.DeleteRepoConfig(ctx, repo))
33+ require.ErrorIs(t, s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo}), domain.ErrNotFound)
34+ require.ErrorIs(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo}), domain.ErrNotFound)
35+ require.ErrorIs(t, s.PutSnapshot(ctx, repo, domain.MirrorSnapshot{}), domain.ErrNotFound)
36+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
37+ g = domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.org:{repo}", Cron: "*/5 * * * *", AllBranches: true, UpdatedAt: created}
38+ require.NoError(t, s.PutGlobal(ctx, g))
39+ gg, err := s.GetGlobal(ctx)
40+ require.NoError(t, err)
41+ require.Equal(t, g, gg)
42+ all := false
43+ c := domain.MirrorRepoConfig{Repo: repo, URL: "git@example.org:override", Cron: "0 * * * *", AllBranches: &all, Excluded: true, UpdatedAt: created}
44+ require.NoError(t, s.PutRepoConfig(ctx, c))
45+ cc, err := s.GetRepoConfig(ctx, repo)
46+ require.NoError(t, err)
47+ require.Equal(t, c, cc)
48+ cs, err := s.ListRepoConfigs(ctx)
49+ require.NoError(t, err)
50+ require.Equal(t, []domain.MirrorRepoConfig{c}, cs)
51+ c.AllBranches = nil
52+ require.NoError(t, s.PutRepoConfig(ctx, c))
53+ cc, err = s.GetRepoConfig(ctx, repo)
54+ require.NoError(t, err)
55+ require.Nil(t, cc.AllBranches)
56+ st := domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, Reason: "retry", Detail: "network", ConsecutiveFailures: 3, LastAttemptAt: created, LastSuccessAt: created, NextRetryAt: created, PausedAt: created, UpdatedAt: created}
57+ require.NoError(t, s.PutStatus(ctx, st))
58+ ss, err := s.GetStatus(ctx, repo)
59+ require.NoError(t, err)
60+ require.Equal(t, st, ss)
61+ statuses, err := s.ListStatus(ctx)
62+ require.NoError(t, err)
63+ require.Equal(t, []domain.MirrorStatus{st}, statuses)
64+ id, err := domain.ParseObjectID("0123456789012345678901234567890123456789")
65+ require.NoError(t, err)
66+ snap := domain.MirrorSnapshot{URL: c.URL, AllBranches: true, At: created, Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: id}}}
67+ require.NoError(t, s.PutSnapshot(ctx, repo, snap))
68+ got, err := s.GetSnapshot(ctx, repo)
69+ require.NoError(t, err)
70+ require.Equal(t, snap, got)
71+ sealed := []byte("opaque sealed seed")
72+ require.NoError(t, s.PutMirrorKey(ctx, sealed, created, true))
73+ require.ErrorIs(t, s.PutMirrorKey(ctx, []byte("other"), created, true), domain.ErrAlreadyExists)
74+ blob, at, err := s.GetMirrorKey(ctx)
75+ require.NoError(t, err)
76+ require.Equal(t, sealed, blob)
77+ require.Equal(t, created, at)
78+ raw, ok := s.RawMirrorKey(ctx)
79+ require.True(t, ok)
80+ require.False(t, bytes.Equal(raw, sealed), "raw record is a protobuf envelope, not bare key material")
81+ require.NoError(t, s.PutMirrorKey(ctx, []byte("rotated"), created, false))
82+ blob, _, err = s.GetMirrorKey(ctx)
83+ require.NoError(t, err)
84+ require.Equal(t, []byte("rotated"), blob)
85+ require.NoError(t, s.Delete(ctx, repo))
86+ _, err = s.GetRepoConfig(ctx, repo)
87+ require.ErrorIs(t, err, domain.ErrNotFound)
88+ _, err = s.GetStatus(ctx, repo)
89+ require.ErrorIs(t, err, domain.ErrNotFound)
90+ _, err = s.GetSnapshot(ctx, repo)
91+ require.ErrorIs(t, err, domain.ErrNotFound)
92+ _, _, err = s.GetMirrorKey(ctx)
93+ require.NoError(t, err)
94+ gg, err = s.GetGlobal(ctx)
95+ require.NoError(t, err)
96+ require.Equal(t, g, gg)
97+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
98+ _, err = s.GetStatus(ctx, repo)
99+ require.ErrorIs(t, err, domain.ErrNotFound)
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+}
@@ -0,0 +1,125 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "strings"
9+ "testing"
10+ "unicode/utf8"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+func TestMirrorRoundTripsAndDelete(t *testing.T) {
18+ ctx := context.Background()
19+ s := openStore(t, t.TempDir())
20+ repo := name(t, "mirror")
21+ g, err := s.GetGlobal(ctx)
22+ require.NoError(t, err)
23+ require.Equal(t, domain.MirrorGlobalConfig{}, g)
24+ _, err = s.GetRepoConfig(ctx, repo)
25+ require.ErrorIs(t, err, domain.ErrNotFound)
26+ _, err = s.GetStatus(ctx, repo)
27+ require.ErrorIs(t, err, domain.ErrNotFound)
28+ _, err = s.GetSnapshot(ctx, repo)
29+ require.ErrorIs(t, err, domain.ErrNotFound)
30+ _, _, err = s.GetMirrorKey(ctx)
31+ require.ErrorIs(t, err, domain.ErrNotFound)
32+ require.NoError(t, s.DeleteRepoConfig(ctx, repo))
33+ require.ErrorIs(t, s.PutRepoConfig(ctx, domain.MirrorRepoConfig{Repo: repo}), domain.ErrNotFound)
34+ require.ErrorIs(t, s.PutStatus(ctx, domain.MirrorStatus{Repo: repo}), domain.ErrNotFound)
35+ require.ErrorIs(t, s.PutSnapshot(ctx, repo, domain.MirrorSnapshot{}), domain.ErrNotFound)
36+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
37+ g = domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.org:{repo}", Cron: "*/5 * * * *", AllBranches: true, UpdatedAt: created}
38+ require.NoError(t, s.PutGlobal(ctx, g))
39+ gg, err := s.GetGlobal(ctx)
40+ require.NoError(t, err)
41+ require.Equal(t, g, gg)
42+ all := false
43+ c := domain.MirrorRepoConfig{Repo: repo, URL: "git@example.org:override", Cron: "0 * * * *", AllBranches: &all, Excluded: true, UpdatedAt: created}
44+ require.NoError(t, s.PutRepoConfig(ctx, c))
45+ cc, err := s.GetRepoConfig(ctx, repo)
46+ require.NoError(t, err)
47+ require.Equal(t, c, cc)
48+ cs, err := s.ListRepoConfigs(ctx)
49+ require.NoError(t, err)
50+ require.Equal(t, []domain.MirrorRepoConfig{c}, cs)
51+ c.AllBranches = nil
52+ require.NoError(t, s.PutRepoConfig(ctx, c))
53+ cc, err = s.GetRepoConfig(ctx, repo)
54+ require.NoError(t, err)
55+ require.Nil(t, cc.AllBranches)
56+ st := domain.MirrorStatus{Repo: repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, Reason: "retry", Detail: "network", ConsecutiveFailures: 3, LastAttemptAt: created, LastSuccessAt: created, NextRetryAt: created, PausedAt: created, UpdatedAt: created}
57+ require.NoError(t, s.PutStatus(ctx, st))
58+ ss, err := s.GetStatus(ctx, repo)
59+ require.NoError(t, err)
60+ require.Equal(t, st, ss)
61+ statuses, err := s.ListStatus(ctx)
62+ require.NoError(t, err)
63+ require.Equal(t, []domain.MirrorStatus{st}, statuses)
64+ id, err := domain.ParseObjectID("0123456789012345678901234567890123456789")
65+ require.NoError(t, err)
66+ snap := domain.MirrorSnapshot{URL: c.URL, AllBranches: true, At: created, Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: id}}}
67+ require.NoError(t, s.PutSnapshot(ctx, repo, snap))
68+ got, err := s.GetSnapshot(ctx, repo)
69+ require.NoError(t, err)
70+ require.Equal(t, snap, got)
71+ sealed := []byte("opaque sealed seed")
72+ require.NoError(t, s.PutMirrorKey(ctx, sealed, created, true))
73+ require.ErrorIs(t, s.PutMirrorKey(ctx, []byte("other"), created, true), domain.ErrAlreadyExists)
74+ blob, at, err := s.GetMirrorKey(ctx)
75+ require.NoError(t, err)
76+ require.Equal(t, sealed, blob)
77+ require.Equal(t, created, at)
78+ raw, ok := s.RawMirrorKey(ctx)
79+ require.True(t, ok)
80+ require.False(t, bytes.Equal(raw, sealed), "raw record is a protobuf envelope, not bare key material")
81+ require.NoError(t, s.PutMirrorKey(ctx, []byte("rotated"), created, false))
82+ blob, _, err = s.GetMirrorKey(ctx)
83+ require.NoError(t, err)
84+ require.Equal(t, []byte("rotated"), blob)
85+ require.NoError(t, s.Delete(ctx, repo))
86+ _, err = s.GetRepoConfig(ctx, repo)
87+ require.ErrorIs(t, err, domain.ErrNotFound)
88+ _, err = s.GetStatus(ctx, repo)
89+ require.ErrorIs(t, err, domain.ErrNotFound)
90+ _, err = s.GetSnapshot(ctx, repo)
91+ require.ErrorIs(t, err, domain.ErrNotFound)
92+ _, _, err = s.GetMirrorKey(ctx)
93+ require.NoError(t, err)
94+ gg, err = s.GetGlobal(ctx)
95+ require.NoError(t, err)
96+ require.Equal(t, g, gg)
97+ require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate}))
98+ _, err = s.GetStatus(ctx, repo)
99+ require.ErrorIs(t, err, domain.ErrNotFound)
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/kvstore/repos.go +1 -0
@@ -164,6 +164,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
164164 keys = append(keys, pendingKeys(ctx, snap, name)...)
165165 keys = append(keys, ciKeys(ctx, snap, name)...)
166166 keys = append(keys, settingsKeys(ctx, snap, name)...)
167+ keys = append(keys, mirrorKeys(ctx, snap, name)...)
167168 for len(keys) > deleteChunk {
168169 var b kv.Batch
169170 for _, k := range keys[:deleteChunk] {
@@ -164,6 +164,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
164 keys = append(keys, pendingKeys(ctx, snap, name)...)164 keys = append(keys, pendingKeys(ctx, snap, name)...)
165 keys = append(keys, ciKeys(ctx, snap, name)...)165 keys = append(keys, ciKeys(ctx, snap, name)...)
166 keys = append(keys, settingsKeys(ctx, snap, name)...)166 keys = append(keys, settingsKeys(ctx, snap, name)...)
167+ keys = append(keys, mirrorKeys(ctx, snap, name)...)
167 for len(keys) > deleteChunk {168 for len(keys) > deleteChunk {
168 var b kv.Batch169 var b kv.Batch
169 for _, k := range keys[:deleteChunk] {170 for _, k := range keys[:deleteChunk] {
M internal/adapters/rpc/api.go +11 -5
@@ -47,11 +47,13 @@ const FeatureCIOIDC = "ci.oidc"
4747
4848 // Config configures the API.
4949 type Config struct {
50- Auth ports.AuthService
51- Repos ports.RepoService
52- Tokens ports.TokenService
53- Browse ports.BrowseService
54- Events ports.EventService
50+ // Mirrors serves MirrorService when set; leave unset rather than a typed nil.
51+ Mirrors ports.MirrorService
52+ Auth ports.AuthService
53+ Repos ports.RepoService
54+ Tokens ports.TokenService
55+ Browse ports.BrowseService
56+ Events ports.EventService
5557 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
5658 MergeRequests ports.MergeRequestService
5759 SigningKey ports.SigningKeyService
@@ -125,6 +127,10 @@ func New(cfg Config) *API {
125127 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
126128 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
127129 features := slices.Clone(Features)
130+ if cfg.Mirrors != nil {
131+ mux.Handle(ermiteconnect.NewMirrorServiceHandler(&mirrorService{cfg.Mirrors}, opts...))
132+ features = append(features, FeatureMirror)
133+ }
128134 if cfg.Passkeys != nil {
129135 features = append(features, FeaturePasskeys)
130136 }
@@ -47,11 +47,13 @@ const FeatureCIOIDC = "ci.oidc"
47 47
48 // Config configures the API.48 // Config configures the API.
49 type Config struct {49 type Config struct {
50- Auth ports.AuthService50+ // Mirrors serves MirrorService when set; leave unset rather than a typed nil.
51- Repos ports.RepoService51+ Mirrors ports.MirrorService
52- Tokens ports.TokenService52+ Auth ports.AuthService
53- Browse ports.BrowseService53+ Repos ports.RepoService
54- Events ports.EventService54+ Tokens ports.TokenService
55+ Browse ports.BrowseService
56+ Events ports.EventService
55 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.57 // MergeRequests serves MergeRequestService, SigningKey GetSigningKey.
56 MergeRequests ports.MergeRequestService58 MergeRequests ports.MergeRequestService
57 SigningKey ports.SigningKeyService59 SigningKey ports.SigningKeyService
@@ -125,6 +127,10 @@ func New(cfg Config) *API {
125 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))127 mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...))
126 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))128 mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...))
127 features := slices.Clone(Features)129 features := slices.Clone(Features)
130+ if cfg.Mirrors != nil {
131+ mux.Handle(ermiteconnect.NewMirrorServiceHandler(&mirrorService{cfg.Mirrors}, opts...))
132+ features = append(features, FeatureMirror)
133+ }
128 if cfg.Passkeys != nil {134 if cfg.Passkeys != nil {
129 features = append(features, FeaturePasskeys)135 features = append(features, FeaturePasskeys)
130 }136 }
M internal/adapters/rpc/convert.go +6 -0
@@ -523,6 +523,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
523523 case domain.JobUpdated:
524524 out.Time = timestamp(ev.At)
525525 out.Kind = jobEventToPB(ev)
526+ case domain.MirrorFailed:
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)}}
529+ case domain.MirrorRecovered:
530+ out.Time = timestamp(ev.At)
531+ out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
526532 default:
527533 // Web-only events, such as domain.IssuePendingRemoved (the API has
528534 // no message for it; watchers list pending issues on demand).
@@ -523,6 +523,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
523 case domain.JobUpdated:523 case domain.JobUpdated:
524 out.Time = timestamp(ev.At)524 out.Time = timestamp(ev.At)
525 out.Kind = jobEventToPB(ev)525 out.Kind = jobEventToPB(ev)
526+ case domain.MirrorFailed:
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)}}
529+ case domain.MirrorRecovered:
530+ out.Time = timestamp(ev.At)
531+ out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}}
526 default:532 default:
527 // Web-only events, such as domain.IssuePendingRemoved (the API has533 // Web-only events, such as domain.IssuePendingRemoved (the API has
528 // no message for it; watchers list pending issues on demand).534 // no message for it; watchers list pending issues on demand).
A internal/adapters/rpc/mirror.go +204 -0
@@ -0,0 +1,204 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "connectrpc.com/connect"
15+)
16+
17+const FeatureMirror = "mirror"
18+
19+type mirrorService struct{ mirrors ports.MirrorService }
20+
21+var _ ermiteconnect.MirrorServiceHandler = (*mirrorService)(nil)
22+
23+func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb.GetMirrorKeyRequest]) (*connect.Response[pb.GetMirrorKeyResponse], error) {
24+ k, err := s.mirrors.ShowKey(ctx, caller(ctx))
25+ if err != nil {
26+ return nil, connectError(ctx, err)
27+ }
28+ return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil
29+}
30+func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) {
31+ r, err := s.mirrors.RotateKey(ctx, caller(ctx))
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(r.Key), PreviousFingerprint: r.Previous.Fingerprint}), nil
36+}
37+func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) {
38+ g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx))
39+ if err != nil {
40+ return nil, connectError(ctx, err)
41+ }
42+ return connect.NewResponse(&pb.GetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil
43+}
44+func (s *mirrorService) SetMirrorConfig(ctx context.Context, req *connect.Request[pb.SetMirrorConfigRequest]) (*connect.Response[pb.SetMirrorConfigResponse], error) {
45+ m := req.Msg
46+ g, err := s.mirrors.SetGlobalConfig(ctx, caller(ctx), ports.MirrorGlobalPatch{Enabled: m.Enabled, URLTemplate: m.UrlTemplate, Cron: m.Cron, AllBranches: m.AllBranches})
47+ if err != nil {
48+ return nil, connectError(ctx, err)
49+ }
50+ return connect.NewResponse(&pb.SetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil
51+}
52+func (s *mirrorService) SetRepoMirror(ctx context.Context, req *connect.Request[pb.SetRepoMirrorRequest]) (*connect.Response[pb.SetRepoMirrorResponse], error) {
53+ m := req.Msg
54+ name, err := domain.ParseRepoName(m.GetRepo())
55+ if err != nil {
56+ return nil, connectError(ctx, err)
57+ }
58+ t, err := s.mirrors.SetRepoMirror(ctx, caller(ctx), domain.MirrorRepoConfig{Repo: name, URL: m.GetUrl(), Cron: m.GetCron(), AllBranches: m.AllBranches})
59+ if err != nil {
60+ return nil, connectError(ctx, err)
61+ }
62+ return connect.NewResponse(&pb.SetRepoMirrorResponse{Target: mirrorTargetToPB(t)}), nil
63+}
64+func (s *mirrorService) RemoveRepoMirror(ctx context.Context, req *connect.Request[pb.RemoveRepoMirrorRequest]) (*connect.Response[pb.RemoveRepoMirrorResponse], error) {
65+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
66+ if err != nil {
67+ return nil, connectError(ctx, err)
68+ }
69+ if err := s.mirrors.RemoveRepoMirror(ctx, caller(ctx), name); err != nil {
70+ return nil, connectError(ctx, err)
71+ }
72+ return connect.NewResponse(&pb.RemoveRepoMirrorResponse{}), nil
73+}
74+func (s *mirrorService) SetRepoMirrorExcluded(ctx context.Context, req *connect.Request[pb.SetRepoMirrorExcludedRequest]) (*connect.Response[pb.SetRepoMirrorExcludedResponse], error) {
75+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
76+ if err != nil {
77+ return nil, connectError(ctx, err)
78+ }
79+ t, err := s.mirrors.SetRepoExcluded(ctx, caller(ctx), name, req.Msg.GetExcluded())
80+ if err != nil {
81+ return nil, connectError(ctx, err)
82+ }
83+ return connect.NewResponse(&pb.SetRepoMirrorExcludedResponse{Target: mirrorTargetToPB(t)}), nil
84+}
85+func (s *mirrorService) GetMirrorStatus(ctx context.Context, req *connect.Request[pb.GetMirrorStatusRequest]) (*connect.Response[pb.GetMirrorStatusResponse], error) {
86+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
87+ if err != nil {
88+ return nil, connectError(ctx, err)
89+ }
90+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), name)
91+ if err != nil {
92+ return nil, connectError(ctx, err)
93+ }
94+ return connect.NewResponse(&pb.GetMirrorStatusResponse{Status: mirrorStatusToPB(v)}), nil
95+}
96+func (s *mirrorService) ListMirrorStatus(ctx context.Context, _ *connect.Request[pb.ListMirrorStatusRequest]) (*connect.Response[pb.ListMirrorStatusResponse], error) {
97+ list, err := s.mirrors.ListStatus(ctx, caller(ctx))
98+ if err != nil {
99+ return nil, connectError(ctx, err)
100+ }
101+ out := make([]*pb.MirrorStatus, 0, len(list))
102+ for _, v := range list {
103+ out = append(out, mirrorStatusToPB(v))
104+ }
105+ return connect.NewResponse(&pb.ListMirrorStatusResponse{Statuses: out}), nil
106+}
107+func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.SyncMirrorRequest]) (*connect.Response[pb.SyncMirrorResponse], error) {
108+ m := req.Msg
109+ if m.GetAll() == (m.GetRepo() != "") {
110+ return nil, connectError(ctx, fmt.Errorf("%w: specify either repo or all", domain.ErrInvalid))
111+ }
112+ var results []ports.MirrorSyncResult
113+ if m.GetAll() {
114+ list, err := s.mirrors.SyncAll(ctx, caller(ctx), m.GetForce())
115+ if err != nil {
116+ return nil, connectError(ctx, err)
117+ }
118+ results = list
119+ } else {
120+ name, err := domain.ParseRepoName(m.GetRepo())
121+ if err != nil {
122+ return nil, connectError(ctx, err)
123+ }
124+ result, err := s.mirrors.Sync(ctx, caller(ctx), name, m.GetForce())
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ result.Repo = name
129+ results = []ports.MirrorSyncResult{result}
130+ }
131+ out := make([]*pb.SyncMirrorResult, 0, len(results))
132+ for _, result := range results {
133+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Repo)
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ if !result.Queued {
138+ v.Status = result.Status
139+ }
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)
145+ }
146+ return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
147+}
148+
149+// syncErrorText is the short reason a repository failed in SyncAll: the failure class only.
150+// The remote's output stays in GetMirrorStatus, so the response stays small and URL-free.
151+func syncErrorText(err error) string {
152+ var me *domain.MirrorError
153+ if errors.As(err, &me) {
154+ return "mirror " + string(me.Class) + ": see ermite mirror status"
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+
167+func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey {
168+ return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)}
169+}
170+func mirrorConfigToPB(g domain.MirrorGlobalConfig) *pb.MirrorConfig {
171+ return &pb.MirrorConfig{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: timestamp(g.UpdatedAt)}
172+}
173+func mirrorStateToPB(s domain.MirrorState) pb.MirrorState {
174+ switch s {
175+ case domain.MirrorDisabled:
176+ return pb.MirrorState_MIRROR_STATE_DISABLED
177+ case domain.MirrorOK:
178+ return pb.MirrorState_MIRROR_STATE_OK
179+ case domain.MirrorFailing:
180+ return pb.MirrorState_MIRROR_STATE_FAILING
181+ case domain.MirrorPaused:
182+ return pb.MirrorState_MIRROR_STATE_PAUSED
183+ default:
184+ return pb.MirrorState_MIRROR_STATE_UNSPECIFIED
185+ }
186+}
187+func mirrorTargetToPB(t domain.MirrorTarget) *pb.MirrorTarget {
188+ source := pb.MirrorSource_MIRROR_SOURCE_UNSPECIFIED
189+ switch t.Source {
190+ case domain.MirrorSourceNone:
191+ source = pb.MirrorSource_MIRROR_SOURCE_DISABLED
192+ case domain.MirrorSourceGlobal:
193+ source = pb.MirrorSource_MIRROR_SOURCE_GLOBAL
194+ case domain.MirrorSourceOverride:
195+ source = pb.MirrorSource_MIRROR_SOURCE_OVERRIDE
196+ case domain.MirrorSourceExcluded:
197+ source = pb.MirrorSource_MIRROR_SOURCE_EXCLUDED
198+ }
199+ return &pb.MirrorTarget{Repo: t.Repo.String(), Enabled: t.Enabled, Url: t.URL, Cron: t.CronSpec, AllBranches: t.AllBranches, Source: source}
200+}
201+func mirrorStatusToPB(v ports.MirrorStatusView) *pb.MirrorStatus {
202+ s := v.Status
203+ return &pb.MirrorStatus{Target: mirrorTargetToPB(v.Target), State: mirrorStateToPB(s.State), FailureClass: string(s.Class), Reason: s.Reason, Detail: s.Detail, ConsecutiveFailures: int32(s.ConsecutiveFailures), LastAttemptAt: timestamp(s.LastAttemptAt), LastSuccessAt: timestamp(s.LastSuccessAt), NextRetryAt: timestamp(s.NextRetryAt), NextRunAt: timestamp(v.NextRunAt), PausedAt: timestamp(s.PausedAt)}
204+}
@@ -0,0 +1,204 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+ "connectrpc.com/connect"
15+)
16+
17+const FeatureMirror = "mirror"
18+
19+type mirrorService struct{ mirrors ports.MirrorService }
20+
21+var _ ermiteconnect.MirrorServiceHandler = (*mirrorService)(nil)
22+
23+func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb.GetMirrorKeyRequest]) (*connect.Response[pb.GetMirrorKeyResponse], error) {
24+ k, err := s.mirrors.ShowKey(ctx, caller(ctx))
25+ if err != nil {
26+ return nil, connectError(ctx, err)
27+ }
28+ return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil
29+}
30+func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) {
31+ r, err := s.mirrors.RotateKey(ctx, caller(ctx))
32+ if err != nil {
33+ return nil, connectError(ctx, err)
34+ }
35+ return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(r.Key), PreviousFingerprint: r.Previous.Fingerprint}), nil
36+}
37+func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) {
38+ g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx))
39+ if err != nil {
40+ return nil, connectError(ctx, err)
41+ }
42+ return connect.NewResponse(&pb.GetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil
43+}
44+func (s *mirrorService) SetMirrorConfig(ctx context.Context, req *connect.Request[pb.SetMirrorConfigRequest]) (*connect.Response[pb.SetMirrorConfigResponse], error) {
45+ m := req.Msg
46+ g, err := s.mirrors.SetGlobalConfig(ctx, caller(ctx), ports.MirrorGlobalPatch{Enabled: m.Enabled, URLTemplate: m.UrlTemplate, Cron: m.Cron, AllBranches: m.AllBranches})
47+ if err != nil {
48+ return nil, connectError(ctx, err)
49+ }
50+ return connect.NewResponse(&pb.SetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil
51+}
52+func (s *mirrorService) SetRepoMirror(ctx context.Context, req *connect.Request[pb.SetRepoMirrorRequest]) (*connect.Response[pb.SetRepoMirrorResponse], error) {
53+ m := req.Msg
54+ name, err := domain.ParseRepoName(m.GetRepo())
55+ if err != nil {
56+ return nil, connectError(ctx, err)
57+ }
58+ t, err := s.mirrors.SetRepoMirror(ctx, caller(ctx), domain.MirrorRepoConfig{Repo: name, URL: m.GetUrl(), Cron: m.GetCron(), AllBranches: m.AllBranches})
59+ if err != nil {
60+ return nil, connectError(ctx, err)
61+ }
62+ return connect.NewResponse(&pb.SetRepoMirrorResponse{Target: mirrorTargetToPB(t)}), nil
63+}
64+func (s *mirrorService) RemoveRepoMirror(ctx context.Context, req *connect.Request[pb.RemoveRepoMirrorRequest]) (*connect.Response[pb.RemoveRepoMirrorResponse], error) {
65+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
66+ if err != nil {
67+ return nil, connectError(ctx, err)
68+ }
69+ if err := s.mirrors.RemoveRepoMirror(ctx, caller(ctx), name); err != nil {
70+ return nil, connectError(ctx, err)
71+ }
72+ return connect.NewResponse(&pb.RemoveRepoMirrorResponse{}), nil
73+}
74+func (s *mirrorService) SetRepoMirrorExcluded(ctx context.Context, req *connect.Request[pb.SetRepoMirrorExcludedRequest]) (*connect.Response[pb.SetRepoMirrorExcludedResponse], error) {
75+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
76+ if err != nil {
77+ return nil, connectError(ctx, err)
78+ }
79+ t, err := s.mirrors.SetRepoExcluded(ctx, caller(ctx), name, req.Msg.GetExcluded())
80+ if err != nil {
81+ return nil, connectError(ctx, err)
82+ }
83+ return connect.NewResponse(&pb.SetRepoMirrorExcludedResponse{Target: mirrorTargetToPB(t)}), nil
84+}
85+func (s *mirrorService) GetMirrorStatus(ctx context.Context, req *connect.Request[pb.GetMirrorStatusRequest]) (*connect.Response[pb.GetMirrorStatusResponse], error) {
86+ name, err := domain.ParseRepoName(req.Msg.GetRepo())
87+ if err != nil {
88+ return nil, connectError(ctx, err)
89+ }
90+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), name)
91+ if err != nil {
92+ return nil, connectError(ctx, err)
93+ }
94+ return connect.NewResponse(&pb.GetMirrorStatusResponse{Status: mirrorStatusToPB(v)}), nil
95+}
96+func (s *mirrorService) ListMirrorStatus(ctx context.Context, _ *connect.Request[pb.ListMirrorStatusRequest]) (*connect.Response[pb.ListMirrorStatusResponse], error) {
97+ list, err := s.mirrors.ListStatus(ctx, caller(ctx))
98+ if err != nil {
99+ return nil, connectError(ctx, err)
100+ }
101+ out := make([]*pb.MirrorStatus, 0, len(list))
102+ for _, v := range list {
103+ out = append(out, mirrorStatusToPB(v))
104+ }
105+ return connect.NewResponse(&pb.ListMirrorStatusResponse{Statuses: out}), nil
106+}
107+func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.SyncMirrorRequest]) (*connect.Response[pb.SyncMirrorResponse], error) {
108+ m := req.Msg
109+ if m.GetAll() == (m.GetRepo() != "") {
110+ return nil, connectError(ctx, fmt.Errorf("%w: specify either repo or all", domain.ErrInvalid))
111+ }
112+ var results []ports.MirrorSyncResult
113+ if m.GetAll() {
114+ list, err := s.mirrors.SyncAll(ctx, caller(ctx), m.GetForce())
115+ if err != nil {
116+ return nil, connectError(ctx, err)
117+ }
118+ results = list
119+ } else {
120+ name, err := domain.ParseRepoName(m.GetRepo())
121+ if err != nil {
122+ return nil, connectError(ctx, err)
123+ }
124+ result, err := s.mirrors.Sync(ctx, caller(ctx), name, m.GetForce())
125+ if err != nil {
126+ return nil, connectError(ctx, err)
127+ }
128+ result.Repo = name
129+ results = []ports.MirrorSyncResult{result}
130+ }
131+ out := make([]*pb.SyncMirrorResult, 0, len(results))
132+ for _, result := range results {
133+ v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Repo)
134+ if err != nil {
135+ return nil, connectError(ctx, err)
136+ }
137+ if !result.Queued {
138+ v.Status = result.Status
139+ }
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)
145+ }
146+ return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil
147+}
148+
149+// syncErrorText is the short reason a repository failed in SyncAll: the failure class only.
150+// The remote's output stays in GetMirrorStatus, so the response stays small and URL-free.
151+func syncErrorText(err error) string {
152+ var me *domain.MirrorError
153+ if errors.As(err, &me) {
154+ return "mirror " + string(me.Class) + ": see ermite mirror status"
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+
167+func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey {
168+ return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)}
169+}
170+func mirrorConfigToPB(g domain.MirrorGlobalConfig) *pb.MirrorConfig {
171+ return &pb.MirrorConfig{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: timestamp(g.UpdatedAt)}
172+}
173+func mirrorStateToPB(s domain.MirrorState) pb.MirrorState {
174+ switch s {
175+ case domain.MirrorDisabled:
176+ return pb.MirrorState_MIRROR_STATE_DISABLED
177+ case domain.MirrorOK:
178+ return pb.MirrorState_MIRROR_STATE_OK
179+ case domain.MirrorFailing:
180+ return pb.MirrorState_MIRROR_STATE_FAILING
181+ case domain.MirrorPaused:
182+ return pb.MirrorState_MIRROR_STATE_PAUSED
183+ default:
184+ return pb.MirrorState_MIRROR_STATE_UNSPECIFIED
185+ }
186+}
187+func mirrorTargetToPB(t domain.MirrorTarget) *pb.MirrorTarget {
188+ source := pb.MirrorSource_MIRROR_SOURCE_UNSPECIFIED
189+ switch t.Source {
190+ case domain.MirrorSourceNone:
191+ source = pb.MirrorSource_MIRROR_SOURCE_DISABLED
192+ case domain.MirrorSourceGlobal:
193+ source = pb.MirrorSource_MIRROR_SOURCE_GLOBAL
194+ case domain.MirrorSourceOverride:
195+ source = pb.MirrorSource_MIRROR_SOURCE_OVERRIDE
196+ case domain.MirrorSourceExcluded:
197+ source = pb.MirrorSource_MIRROR_SOURCE_EXCLUDED
198+ }
199+ return &pb.MirrorTarget{Repo: t.Repo.String(), Enabled: t.Enabled, Url: t.URL, Cron: t.CronSpec, AllBranches: t.AllBranches, Source: source}
200+}
201+func mirrorStatusToPB(v ports.MirrorStatusView) *pb.MirrorStatus {
202+ s := v.Status
203+ return &pb.MirrorStatus{Target: mirrorTargetToPB(v.Target), State: mirrorStateToPB(s.State), FailureClass: string(s.Class), Reason: s.Reason, Detail: s.Detail, ConsecutiveFailures: int32(s.ConsecutiveFailures), LastAttemptAt: timestamp(s.LastAttemptAt), LastSuccessAt: timestamp(s.LastSuccessAt), NextRetryAt: timestamp(s.NextRetryAt), NextRunAt: timestamp(v.NextRunAt), PausedAt: timestamp(s.PausedAt)}
204+}
A internal/adapters/rpc/mirror_test.go +108 -0
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "connectrpc.com/connect"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func TestMirrorConfigPatchPresence(t *testing.T) {
21+ service := mocks.NewMirrorService(t)
22+ disabled := false
23+ empty := ""
24+ patch := ports.MirrorGlobalPatch{Enabled: &disabled, URLTemplate: &empty}
25+ service.EXPECT().SetGlobalConfig(mock.Anything, domain.Principal{}, patch).Return(domain.MirrorGlobalConfig{}, nil).Once()
26+ s := &mirrorService{service}
27+ res, err := s.SetMirrorConfig(context.Background(), connect.NewRequest(&pb.SetMirrorConfigRequest{Enabled: &disabled, UrlTemplate: &empty}))
28+ require.NoError(t, err)
29+ assert.False(t, res.Msg.GetConfig().GetEnabled())
30+}
31+
32+func TestMirrorSyncSelector(t *testing.T) {
33+ s := &mirrorService{mocks.NewMirrorService(t)}
34+ for _, req := range []*pb.SyncMirrorRequest{{}, {Repo: "repo", All: true}, {Repo: "../invalid"}} {
35+ _, err := s.SyncMirror(context.Background(), connect.NewRequest(req))
36+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
37+ }
38+}
39+
40+func TestMirrorSyncResponse(t *testing.T) {
41+ service := mocks.NewMirrorService(t)
42+ name, err := domain.ParseRepoName("repo")
43+ require.NoError(t, err)
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}
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()
48+ s := &mirrorService{service}
49+ res, err := s.SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{Repo: "repo", Force: true}))
50+ require.NoError(t, err)
51+ require.Len(t, res.Msg.Results, 1)
52+ assert.Equal(t, "repo", res.Msg.Results[0].Repo)
53+ assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[0].Status.State)
54+ assert.Equal(t, "diverged", res.Msg.Results[0].Status.FailureClass)
55+}
56+
57+func TestMirrorEvents(t *testing.T) {
58+ name, err := domain.ParseRepoName("repo")
59+ require.NoError(t, err)
60+ now := time.Now().UTC()
61+ failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now}})
62+ require.NotNil(t, failed)
63+ assert.Equal(t, now, failed.Time.AsTime())
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}})
66+ require.NotNil(t, recovered)
67+ assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo)
68+ // The public event messages deliberately expose neither URL nor stderr detail.
69+ fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields()
70+ assert.Nil(t, fields.ByName("url"))
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: see ermite mirror status", 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
108+}
@@ -0,0 +1,108 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ pb "bnema.dev/ermite-api/gen/ermite"
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "connectrpc.com/connect"
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/mock"
17+ "github.com/stretchr/testify/require"
18+)
19+
20+func TestMirrorConfigPatchPresence(t *testing.T) {
21+ service := mocks.NewMirrorService(t)
22+ disabled := false
23+ empty := ""
24+ patch := ports.MirrorGlobalPatch{Enabled: &disabled, URLTemplate: &empty}
25+ service.EXPECT().SetGlobalConfig(mock.Anything, domain.Principal{}, patch).Return(domain.MirrorGlobalConfig{}, nil).Once()
26+ s := &mirrorService{service}
27+ res, err := s.SetMirrorConfig(context.Background(), connect.NewRequest(&pb.SetMirrorConfigRequest{Enabled: &disabled, UrlTemplate: &empty}))
28+ require.NoError(t, err)
29+ assert.False(t, res.Msg.GetConfig().GetEnabled())
30+}
31+
32+func TestMirrorSyncSelector(t *testing.T) {
33+ s := &mirrorService{mocks.NewMirrorService(t)}
34+ for _, req := range []*pb.SyncMirrorRequest{{}, {Repo: "repo", All: true}, {Repo: "../invalid"}} {
35+ _, err := s.SyncMirror(context.Background(), connect.NewRequest(req))
36+ assert.Equal(t, connect.CodeInvalidArgument, connect.CodeOf(err))
37+ }
38+}
39+
40+func TestMirrorSyncResponse(t *testing.T) {
41+ service := mocks.NewMirrorService(t)
42+ name, err := domain.ParseRepoName("repo")
43+ require.NoError(t, err)
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}
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()
48+ s := &mirrorService{service}
49+ res, err := s.SyncMirror(context.Background(), connect.NewRequest(&pb.SyncMirrorRequest{Repo: "repo", Force: true}))
50+ require.NoError(t, err)
51+ require.Len(t, res.Msg.Results, 1)
52+ assert.Equal(t, "repo", res.Msg.Results[0].Repo)
53+ assert.Equal(t, pb.MirrorState_MIRROR_STATE_PAUSED, res.Msg.Results[0].Status.State)
54+ assert.Equal(t, "diverged", res.Msg.Results[0].Status.FailureClass)
55+}
56+
57+func TestMirrorEvents(t *testing.T) {
58+ name, err := domain.ParseRepoName("repo")
59+ require.NoError(t, err)
60+ now := time.Now().UTC()
61+ failed := watchEventToPB(ports.WatchEvent{Event: domain.MirrorFailed{Repo: name, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now}})
62+ require.NotNil(t, failed)
63+ assert.Equal(t, now, failed.Time.AsTime())
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}})
66+ require.NotNil(t, recovered)
67+ assert.Equal(t, "repo", recovered.GetMirrorRecovered().Repo)
68+ // The public event messages deliberately expose neither URL nor stderr detail.
69+ fields := failed.GetMirrorFailed().ProtoReflect().Descriptor().Fields()
70+ assert.Nil(t, fields.ByName("url"))
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: see ermite mirror status", 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
108+}
A internal/adapters/web/mirror.go +27 -0
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
10+
11+// MirrorBadge is an administrative diagnostic in the summary rail.
12+type MirrorBadge struct{ State, Reason, Command string }
13+
14+// mirrorBadge selects presentation only; the source authorizes the lookup and reason.
15+//
16+//hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
17+func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
18+ p := principal(ctx)
19+ if s.cfg.Mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
20+ return nil
21+ }
22+ state, reason := s.cfg.Mirrors.Badge(ctx, p, repo)
23+ if state != domain.MirrorFailing && state != domain.MirrorPaused {
24+ return nil
25+ }
26+ return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()}
27+}
@@ -0,0 +1,27 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
10+
11+// MirrorBadge is an administrative diagnostic in the summary rail.
12+type MirrorBadge struct{ State, Reason, Command string }
13+
14+// mirrorBadge selects presentation only; the source authorizes the lookup and reason.
15+//
16+//hexcheck:ignore suspicious-business-logic-in-adapter controls visibility of an administrative UI indicator
17+func (s *Server) mirrorBadge(ctx context.Context, repo domain.RepoName) *MirrorBadge {
18+ p := principal(ctx)
19+ if s.cfg.Mirrors == nil || !p.Allows(domain.ScopeRepoAdmin, repo) {
20+ return nil
21+ }
22+ state, reason := s.cfg.Mirrors.Badge(ctx, p, repo)
23+ if state != domain.MirrorFailing && state != domain.MirrorPaused {
24+ return nil
25+ }
26+ return &MirrorBadge{State: string(state), Reason: reason, Command: "ermite mirror status " + repo.String()}
27+}
A internal/adapters/web/mirror_test.go +53 -0
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+)
14+
15+func TestMirrorBadge(t *testing.T) {
16+ repo := repoName(t, "proj")
17+ admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}})
18+ reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}})
19+ for _, tc := range []struct {
20+ name string
21+ p domain.Principal
22+ state domain.MirrorState
23+ shown bool
24+ lookup bool
25+ }{
26+ {"owner failing", domain.Owner(), domain.MirrorFailing, true, true},
27+ {"admin paused", admin, domain.MirrorPaused, true, true},
28+ {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false},
29+ {"reader", reader, domain.MirrorFailing, false, false},
30+ {"ok", admin, domain.MirrorOK, false, true},
31+ {"disabled", admin, domain.MirrorDisabled, false, true},
32+ } {
33+ t.Run(tc.name, func(t *testing.T) {
34+ w := mocks.NewMirrorWarner(t)
35+ if tc.lookup {
36+ w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
37+ }
38+ s := &Server{cfg: Config{Mirrors: w}}
39+ badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
40+ out := renderRail(t, SummaryPage{Mirror: badge})
41+ if tc.shown {
42+ assert.Contains(t, out, `aria-labelledby="r-mirror"`)
43+ assert.Contains(t, out, "Mirror "+string(tc.state))
44+ assert.Contains(t, out, "main diverged on remote")
45+ assert.Contains(t, out, "ermite mirror status proj")
46+ } else {
47+ assert.Nil(t, badge)
48+ assert.NotContains(t, out, `id="r-mirror"`)
49+ }
50+ })
51+ }
52+ assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo))
53+}
@@ -0,0 +1,53 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports/mocks"
13+)
14+
15+func TestMirrorBadge(t *testing.T) {
16+ repo := repoName(t, "proj")
17+ admin := domain.TokenPrincipal(domain.Token{ID: "admin", Scopes: []domain.Scope{domain.ScopeRepoAdmin}, Repos: []domain.RepoName{repo}})
18+ reader := domain.TokenPrincipal(domain.Token{ID: "reader", Scopes: []domain.Scope{domain.ScopeRepoRead}})
19+ for _, tc := range []struct {
20+ name string
21+ p domain.Principal
22+ state domain.MirrorState
23+ shown bool
24+ lookup bool
25+ }{
26+ {"owner failing", domain.Owner(), domain.MirrorFailing, true, true},
27+ {"admin paused", admin, domain.MirrorPaused, true, true},
28+ {"anonymous", domain.Anonymous(), domain.MirrorFailing, false, false},
29+ {"reader", reader, domain.MirrorFailing, false, false},
30+ {"ok", admin, domain.MirrorOK, false, true},
31+ {"disabled", admin, domain.MirrorDisabled, false, true},
32+ } {
33+ t.Run(tc.name, func(t *testing.T) {
34+ w := mocks.NewMirrorWarner(t)
35+ if tc.lookup {
36+ w.EXPECT().Badge(mock.Anything, tc.p, repo).Return(tc.state, "main diverged on remote")
37+ }
38+ s := &Server{cfg: Config{Mirrors: w}}
39+ badge := s.mirrorBadge(domain.ContextWithPrincipal(t.Context(), tc.p), repo)
40+ out := renderRail(t, SummaryPage{Mirror: badge})
41+ if tc.shown {
42+ assert.Contains(t, out, `aria-labelledby="r-mirror"`)
43+ assert.Contains(t, out, "Mirror "+string(tc.state))
44+ assert.Contains(t, out, "main diverged on remote")
45+ assert.Contains(t, out, "ermite mirror status proj")
46+ } else {
47+ assert.Nil(t, badge)
48+ assert.NotContains(t, out, `id="r-mirror"`)
49+ }
50+ })
51+ }
52+ assert.Nil(t, (&Server{}).mirrorBadge(domain.ContextWithPrincipal(t.Context(), domain.Owner()), repo))
53+}
M internal/adapters/web/pages.templ +11 -0
@@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) {
9292 }
9393
9494 templ summaryRail(p SummaryPage) {
95+ if p.Mirror != nil {
96+ <section aria-labelledby="r-mirror">
97+ <h2 id="r-mirror">Mirror</h2>
98+ <p>⚠ Mirror { p.Mirror.State }</p>
99+ if p.Mirror.Reason != "" {
100+ <p>{ p.Mirror.Reason }</p>
101+ }
102+ <p>Run <code>{ p.Mirror.Command }</code></p>
103+ </section>
104+ }
105+
95106 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
96107 </section>
97108 <section aria-labelledby="r-facts">
@@ -92,6 +92,17 @@ templ latestTagFact(t LatestTag) {
92 }92 }
93 93
94 templ summaryRail(p SummaryPage) {94 templ summaryRail(p SummaryPage) {
95+ if p.Mirror != nil {
96+ <section aria-labelledby="r-mirror">
97+ <h2 id="r-mirror">Mirror</h2>
98+ <p>⚠ Mirror { p.Mirror.State }</p>
99+ if p.Mirror.Reason != "" {
100+ <p>{ p.Mirror.Reason }</p>
101+ }
102+ <p>Run <code>{ p.Mirror.Command }</code></p>
103+ </section>
104+ }
105+
95 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)106 <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone)
96 </section>107 </section>
97 <section aria-labelledby="r-facts">108 <section aria-labelledby="r-facts">
M internal/adapters/web/pages_templ.go +324 -269
M internal/adapters/web/summary.go +1 -0
M internal/adapters/web/view.go +1 -0
M internal/adapters/web/web.go +3 -0
M internal/app/api_test.go +1 -1
M internal/app/cli_e2e_test.go +1 -1
A internal/app/cli_mirror.go +320 -0
A internal/app/cli_mirror_e2e_test.go +122 -0
A internal/app/cli_mirror_test.go +67 -0
M internal/app/cli_watch.go +14 -0
M internal/app/cli_watch_test.go +17 -0
M internal/app/forge.go +33 -1
M internal/app/root.go +1 -1
M internal/app/web.go +1 -0
M internal/domain/event.go +22 -0
A internal/domain/mirror.go +254 -0
A internal/domain/mirror_cron.go +91 -0
A internal/domain/mirror_key.go +39 -0
A internal/domain/mirror_refs.go +57 -0
A internal/domain/mirror_state.go +202 -0
A internal/domain/mirror_test.go +302 -0
A internal/ports/mirror.go +122 -0
A internal/ports/mocks/MirrorKeyStore.go +185 -0
A internal/ports/mocks/MirrorPusher.go +114 -0
A internal/ports/mocks/MirrorService.go +824 -0
A internal/ports/mocks/MirrorStore.go +721 -0
A internal/ports/mocks/MirrorTrigger.go +100 -0
A internal/ports/mocks/MirrorWarner.go +183 -0
M internal/usecase/events.go +8 -0
M internal/usecase/mergerequests.go +10 -0
A internal/usecase/mirrors.go +91 -0
A internal/usecase/mirrors_config.go +365 -0
A internal/usecase/mirrors_config_test.go +276 -0
A internal/usecase/mirrors_fixture_test.go +266 -0
A internal/usecase/mirrors_key.go +131 -0
A internal/usecase/mirrors_key_fixture_test.go +52 -0
A internal/usecase/mirrors_notify_test.go +112 -0
A internal/usecase/mirrors_schedule.go +154 -0
A internal/usecase/mirrors_sync.go +299 -0
A internal/usecase/mirrors_sync_test.go +492 -0
M internal/usecase/pushes.go +10 -0
M internal/usecase/repos.go +13 -5
M internal/usecase/repos_write.go +3 -0
A proto/ermite/storage/mirror.proto +45 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 41

↑↓ moveenter openesc close