detached 5411f5d

Merge 067ccd6a6bf0f9be62c0536cd1d13c79957cb708 into 55fcc8fd17ec2d8214aafd64e606433db222abb7

commit
5411f5d19f46040bf578f6f8f019354424d359f8
author
ermite · · 2026-10-20 12:10 UTC
merge of
55fcc8f + 067ccd6 · diff against the first parent
signature
unsigned
69 files · +8558 -290
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)
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.
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.
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.
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
M go.sum +2 -2
@@ -1,7 +1,7 @@
11 bnema.dev/ermite-actions v0.2.0 h1:238UBhSZcm8RxqqjZvX3RLGKQNyKtxoXKnKIkYt4P/M=
22 bnema.dev/ermite-actions v0.2.0/go.mod h1:qoIB/Iz4xKdLUG4R6wuBDk6cemdugR+UEo5bpSJJYh4=
3-bnema.dev/ermite-api v0.11.0 h1:XgD8lBfI/HtnuNVvBkt+QyHu1nTItACZj53c3wY+Pvo=
4-bnema.dev/ermite-api v0.11.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
3+bnema.dev/ermite-api v0.12.0 h1:lC2PzxYVkN2vZ9QKLUEJ125KhLmA7rgPvz+bqz0GD8Y=
4+bnema.dev/ermite-api v0.12.0/go.mod h1:L5smjt+IAigyAo3Ny2EnFB9IwvIGOPT1GoByZNgbDa8=
55 bnema.dev/ermite-git v0.1.0 h1:8GNhQDj0MxyvrI4kHewZ9k0rT86e9348T3BJ49WHfmI=
66 bnema.dev/ermite-git v0.1.0/go.mod h1:m9d4VYHm28mrY6VxVqWQMRIsadGq80FeLub/nrZX6Cg=
77 bnema.dev/ermite-kv v0.1.0 h1:NbD76jgqMq08AVTe/l0EEqDTHjywaYNoqC5c/WgYRcE=
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...)
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
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+}
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+}
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+}
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+}
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+}
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+}
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+}
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+}
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] {
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 }
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).
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+}
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+}
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+}
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+}
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">
M internal/adapters/web/pages_templ.go +324 -269 generated file: collapsed
M internal/adapters/web/summary.go +1 -0
@@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
4949 now := s.cfg.Now()
5050
5151 p := SummaryPage{
52+ Mirror: s.mirrorBadge(ctx, d.repo.Name),
5253 AllHref: repoHref(d.name(), "commits", ref),
5354 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
5455 }
M internal/adapters/web/view.go +1 -0
@@ -203,6 +203,7 @@ type CommitRow struct {
203203
204204 // SummaryPage is the repository summary.
205205 type SummaryPage struct {
206+ Mirror *MirrorBadge
206207 Facts []Fact
207208 Clone Clone
208209 Recent []CommitRow
M internal/adapters/web/web.go +3 -0
@@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second
4747
4848 // Config configures a Server.
4949 type Config struct {
50+ // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51+ // no badge. Leave it unset rather than assigning a typed nil pointer.
52+ Mirrors ports.MirrorWarner
5053 // Repos and Browse are the use cases the pages are built from.
5154 Repos ports.RepoService
5255 Browse ports.BrowseService
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
A internal/app/cli_mirror.go +320 -0
@@ -0,0 +1,320 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/spf13/cobra"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+const jsonMirrorKeyHelp = `
17+JSON fields of a key (--json): authorized_key, fingerprint, created_at (RFC 3339, UTC).`
18+
19+const jsonMirrorConfigHelp = `
20+JSON fields of the policy (--json): enabled, url_template, cron, all_branches, updated_at (RFC 3339, UTC, null if never set).`
21+
22+const jsonMirrorStatusHelp = `
23+JSON fields of a status (--json): repo, enabled, source (disabled, global, override, excluded), url
24+(admins only, else empty), cron, all_branches, state (disabled, ok, failing, paused), failure_class,
25+reason, detail (admins only), consecutive_failures, last_attempt_at, last_success_at, next_retry_at,
26+next_run_at, paused_at (RFC 3339, UTC, null when unset).`
27+
28+type jsonMirrorKey struct {
29+ AuthorizedKey string `json:"authorized_key"`
30+ Fingerprint string `json:"fingerprint"`
31+ CreatedAt *string `json:"created_at"`
32+}
33+
34+type jsonMirrorConfig struct {
35+ Enabled bool `json:"enabled"`
36+ URLTemplate string `json:"url_template"`
37+ Cron string `json:"cron"`
38+ AllBranches bool `json:"all_branches"`
39+ UpdatedAt *string `json:"updated_at"`
40+}
41+
42+type jsonMirrorStatus struct {
43+ Repo string `json:"repo"`
44+ Enabled bool `json:"enabled"`
45+ Source string `json:"source"`
46+ URL string `json:"url"`
47+ Cron string `json:"cron"`
48+ AllBranches bool `json:"all_branches"`
49+ State string `json:"state"`
50+ FailureClass string `json:"failure_class"`
51+ Reason string `json:"reason"`
52+ Detail string `json:"detail"`
53+ ConsecutiveFailures int32 `json:"consecutive_failures"`
54+ LastAttemptAt *string `json:"last_attempt_at"`
55+ LastSuccessAt *string `json:"last_success_at"`
56+ NextRetryAt *string `json:"next_retry_at"`
57+ NextRunAt *string `json:"next_run_at"`
58+ PausedAt *string `json:"paused_at"`
59+}
60+
61+func toJSONMirrorKey(k *ermite.MirrorKey) jsonMirrorKey {
62+ return jsonMirrorKey{AuthorizedKey: k.GetAuthorizedKey(), Fingerprint: k.GetFingerprint(), CreatedAt: jsonTime(k.GetCreatedAt())}
63+}
64+
65+func toJSONMirrorConfig(g *ermite.MirrorConfig) jsonMirrorConfig {
66+ return jsonMirrorConfig{Enabled: g.GetEnabled(), URLTemplate: g.GetUrlTemplate(), Cron: g.GetCron(), AllBranches: g.GetAllBranches(), UpdatedAt: jsonTime(g.GetUpdatedAt())}
67+}
68+
69+func mirrorSourceName(s ermite.MirrorSource) string {
70+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_SOURCE_"))
71+}
72+
73+func toJSONMirrorStatus(s *ermite.MirrorStatus) jsonMirrorStatus {
74+ t := s.GetTarget()
75+ return jsonMirrorStatus{
76+ Repo: t.GetRepo(), Enabled: t.GetEnabled(), Source: mirrorSourceName(t.GetSource()), URL: t.GetUrl(), Cron: t.GetCron(), AllBranches: t.GetAllBranches(),
77+ State: mirrorStateName(s.GetState()), FailureClass: s.GetFailureClass(), Reason: s.GetReason(), Detail: s.GetDetail(), ConsecutiveFailures: s.GetConsecutiveFailures(),
78+ LastAttemptAt: jsonTime(s.GetLastAttemptAt()), LastSuccessAt: jsonTime(s.GetLastSuccessAt()), NextRetryAt: jsonTime(s.GetNextRetryAt()), NextRunAt: jsonTime(s.GetNextRunAt()), PausedAt: jsonTime(s.GetPausedAt()),
79+ }
80+}
81+
82+func newMirrorCmd(env *cliEnv) *cobra.Command {
83+ cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
84+ key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
85+ for _, rotate := range []bool{false, true} {
86+ name, short := "show", "Print the mirror authorized_keys line"
87+ long := "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe it into: gh ssh-key add - --title \"ermite mirror\"." + jsonMirrorKeyHelp
88+ if rotate {
89+ name, short = "rotate", "Replace the mirror SSH key (owner only)"
90+ long = "Replace the mirror SSH key at once and print the new authorized_keys line on stdout; the replaced\nkey's fingerprint goes to stderr. Every mirror fails until the new key is added to the remotes.\nRequires owner rights and ERMITE_SECRETS_KEY on the server." + jsonMirrorKeyHelp
91+ }
92+ sub := &cobra.Command{Use: name, Short: short, Args: cobra.NoArgs, Long: long}
93+ asJSON := addJSONFlag(sub)
94+ sub.RunE = func(cmd *cobra.Command, _ []string) error {
95+ c, err := env.client(cmd)
96+ if err != nil {
97+ return err
98+ }
99+ var k *ermite.MirrorKey
100+ if rotate {
101+ res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{}))
102+ if e != nil {
103+ return c.Describe(e)
104+ }
105+ k = res.Msg.GetKey()
106+ if prev := res.Msg.GetPreviousFingerprint(); prev != "" {
107+ noticef(cmd, "replaced key %s", clean(prev))
108+ }
109+ noticef(cmd, "remotes refuse pushes until this key is added")
110+ } else {
111+ res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
112+ if e != nil {
113+ return c.Describe(e)
114+ }
115+ k = res.Msg.GetKey()
116+ }
117+ if *asJSON {
118+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k))
119+ }
120+ _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
121+ return err
122+ }
123+ key.AddCommand(sub)
124+ }
125+ cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env))
126+ for _, name := range []string{"remove", "exclude", "include"} {
127+ sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)}
128+ sub.RunE = func(cmd *cobra.Command, args []string) error {
129+ c, err := env.client(cmd)
130+ if err != nil {
131+ return err
132+ }
133+ if name == "remove" {
134+ _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]}))
135+ } else {
136+ _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"}))
137+ }
138+ if err != nil {
139+ return c.Describe(err)
140+ }
141+ noticef(cmd, "%s: %s", name, clean(args[0]))
142+ return nil
143+ }
144+ cmd.AddCommand(sub)
145+ }
146+ return cmd
147+}
148+
149+func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
150+ var enabled, all bool
151+ var url, cron string
152+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Long: "Change the flags given and keep the other settings; use `config show` to read the policy." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
153+ cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
154+ cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
155+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
156+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch")
157+ asJSON := addJSONFlag(cmd)
158+ output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
159+ if json {
160+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g))
161+ }
162+ return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
163+ }
164+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
165+ c, err := env.client(cmd)
166+ if err != nil {
167+ return err
168+ }
169+ req := &ermite.SetMirrorConfigRequest{}
170+ if cmd.Flags().Changed("enabled") {
171+ req.Enabled = &enabled
172+ }
173+ if cmd.Flags().Changed("url") {
174+ req.UrlTemplate = &url
175+ }
176+ if cmd.Flags().Changed("cron") {
177+ req.Cron = &cron
178+ }
179+ if cmd.Flags().Changed("all-branches") {
180+ req.AllBranches = &all
181+ }
182+ if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil {
183+ return fmt.Errorf("specify at least one config flag, or use `mirror config show`")
184+ }
185+ res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req))
186+ if err != nil {
187+ return c.Describe(err)
188+ }
189+ return output(cmd, res.Msg.GetConfig(), *asJSON)
190+ }
191+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Long: "Show the global mirror policy (owner only)." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
192+ showJSON := addJSONFlag(show)
193+ show.RunE = func(cmd *cobra.Command, _ []string) error {
194+ c, err := env.client(cmd)
195+ if err != nil {
196+ return err
197+ }
198+ res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{}))
199+ if err != nil {
200+ return c.Describe(err)
201+ }
202+ return output(cmd, res.Msg.GetConfig(), *showJSON)
203+ }
204+ cmd.AddCommand(show)
205+ return cmd
206+}
207+
208+func newMirrorAddCmd(env *cliEnv) *cobra.Command {
209+ var cron string
210+ var all bool
211+ cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)}
212+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)")
213+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)")
214+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
215+ c, err := env.client(cmd)
216+ if err != nil {
217+ return err
218+ }
219+ req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron}
220+ if cmd.Flags().Changed("all-branches") {
221+ req.AllBranches = &all
222+ }
223+ _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req))
224+ if err != nil {
225+ return c.Describe(err)
226+ }
227+ noticef(cmd, "mirror configured for %s", clean(args[0]))
228+ return nil
229+ }
230+ return cmd
231+}
232+
233+func mirrorStateName(s ermite.MirrorState) string {
234+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_"))
235+}
236+
237+func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
238+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Long: "Show one repository, or every repository (owner only).\n--json prints an object for one repository and an array for all." + jsonMirrorStatusHelp, Args: cobra.MaximumNArgs(1)}
239+ asJSON := addJSONFlag(cmd)
240+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
241+ c, err := env.client(cmd)
242+ if err != nil {
243+ return err
244+ }
245+ var statuses []*ermite.MirrorStatus
246+ if len(args) == 1 {
247+ res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]}))
248+ if err != nil {
249+ return c.Describe(err)
250+ }
251+ if *asJSON {
252+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus()))
253+ }
254+ statuses = append(statuses, res.Msg.GetStatus())
255+ } else {
256+ res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{}))
257+ if err != nil {
258+ return c.Describe(err)
259+ }
260+ if *asJSON {
261+ items := make([]jsonMirrorStatus, 0, len(res.Msg.GetStatuses()))
262+ for _, st := range res.Msg.GetStatuses() {
263+ items = append(items, toJSONMirrorStatus(st))
264+ }
265+ return writeJSON(cmd.OutOrStdout(), items)
266+ }
267+ statuses = res.Msg.GetStatuses()
268+ }
269+ rows := make([][]string, 0, len(statuses))
270+ for _, s := range statuses {
271+ rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))})
272+ }
273+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows)
274+ }
275+ return cmd
276+}
277+
278+func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
279+ var all, force bool
280+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281+ if (all && len(args) == 0) || (!all && len(args) == 1) {
282+ return nil
283+ }
284+ return fmt.Errorf("specify exactly one repository or --all")
285+ }}
286+ cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
287+ cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
288+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
289+ c, err := env.client(cmd)
290+ if err != nil {
291+ return err
292+ }
293+ req := &ermite.SyncMirrorRequest{All: all, Force: force}
294+ if !all {
295+ req.Repo = args[0]
296+ }
297+ res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
298+ if err != nil {
299+ return c.Describe(err)
300+ }
301+ failed := 0
302+ rows := make([][]string, 0, len(res.Msg.GetResults()))
303+ for _, r := range res.Msg.GetResults() {
304+ reason := r.GetStatus().GetReason()
305+ if r.GetError() != "" {
306+ failed++
307+ reason = r.GetError()
308+ }
309+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
310+ }
311+ if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312+ return err
313+ }
314+ if failed > 0 {
315+ return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316+ }
317+ return nil
318+ }
319+ return cmd
320+}
A internal/app/cli_mirror_e2e_test.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "golang.org/x/crypto/ssh"
15+
16+ "bnema.dev/ermite/internal/adapters/gitcli"
17+ "bnema.dev/ermite/internal/adapters/gitpush"
18+ "bnema.dev/ermite/internal/config"
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// Pushes mark mirrors dirty; a due cron tick performs the asynchronous sync.
24+// Advancing the injected clock exercises that contract without waiting five minutes.
25+func TestMirrorCLIEndToEnd(t *testing.T) {
26+ clk := &testClock{}
27+ clk.set("2025-12-31 23:59:00")
28+ transport := t.TempDir()
29+ var dataDir string
30+ h := newHarnessWith(t, harnessOpts{env: map[string]string{config.EnvSecretsKey: secretsKeyA}}, func(o *options) {
31+ o.mirrors = func(c *usecase.MirrorsConfig) {
32+ c.ValidateURL = func(string) error { return nil } // file:// remotes
33+ c.Clock = clk
34+ c.ScheduleTick = time.Hour
35+ c.Pusher = gitpush.New(gitpush.Config{
36+ Bin: requireGit(t), Path: os.Getenv("PATH"), Home: transport,
37+ KnownHosts: filepath.Join(transport, "known_hosts"), TempDir: filepath.Join(transport, "keys"),
38+ AllowedProtocols: "file", RepoDir: func(repo domain.RepoName) string { return gitcli.RepoDir(dataDir, repo) },
39+ })
40+ }
41+ })
42+ // The transport needs the harness data directory, which is known after newForge.
43+ // Replace the test-only closure's captured directory before any sync is queued.
44+ dataDir = h.data
45+ c := &cliHarness{harness: h, url: h.srv.URL}
46+ run := func(args ...string) result { return c.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
47+ h.createRepo("proj", domain.VisibilityPrivate)
48+ remote := filepath.Join(t.TempDir(), "remote.git")
49+ h.mustGit(transport, "init", "--bare", remote)
50+ run("add", "proj", "file://"+remote, "--cron", "*/5 * * * *").ok(t)
51+ key := run("key", "show").ok(t)
52+ assert.Empty(t, key.stderr)
53+ pub, _, _, rest, err := ssh.ParseAuthorizedKey([]byte(key.stdout))
54+ require.NoError(t, err)
55+ assert.Empty(t, rest)
56+ assert.Equal(t, "ssh-ed25519", pub.Type())
57+ assert.Equal(t, 1, strings.Count(key.stdout, "\n"))
58+ assert.Contains(t, c.cli(t, adminToken, "version").ok(t).stdout, "mirror")
59+ h.forge.Mirrors.Start()
60+ events := h.forge.Bus.Subscribe(h.ctx)
61+ wt := h.workTree("main", "README.md")
62+ h.mustGit(wt, "tag", "v1")
63+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main", "--tags")
64+ clk.set("2026-01-01 00:00:00")
65+ h.forge.Mirrors.ScheduleNow(h.ctx)
66+ require.Eventually(t, func() bool {
67+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
68+ return err == nil && !s.Status.LastSuccessAt.IsZero()
69+ }, 10*time.Second, 10*time.Millisecond)
70+ main := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
71+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
72+ assert.Equal(t, main, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/tags/v1")))
73+ clk.set("2026-01-01 00:02:00")
74+ assert.Contains(t, run("sync", "proj").ok(t).stdout, "true", "unchanged refs are a no-op")
75+
76+ // Advance the remote independently, so the next atomic mirror push is rejected.
77+ other := filepath.Join(t.TempDir(), "other")
78+ h.mustGit(transport, "clone", "--branch", "main", remote, other)
79+ h.mustGit(other, "commit", "--allow-empty", "-m", "remote-only")
80+ h.mustGit(other, "push", "origin", "main")
81+ h.mustGit(wt, "commit", "--allow-empty", "-m", "forge-only")
82+ h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
83+ clk.set("2026-01-01 00:05:00")
84+ h.forge.Mirrors.ScheduleNow(h.ctx)
85+ require.Eventually(t, func() bool {
86+ s, err := h.forge.Mirrors.GetStatus(h.ctx, domain.Root(), h.name("proj"))
87+ return err == nil && s.Status.State == domain.MirrorPaused
88+ }, 10*time.Second, 10*time.Millisecond)
89+ status := run("status", "proj").ok(t).stdout
90+ assert.Contains(t, status, "paused")
91+ assert.Contains(t, status, "remote has commits")
92+ assert.Contains(t, run("status", "proj", "--json").ok(t).stdout, `"failure_class": "diverged"`)
93+ h.mustGit(wt, "commit", "--allow-empty", "-m", "warning")
94+ warning := h.mustGit(wt, "push", h.urlAuth(adminToken, "/proj.git"), "main")
95+ assert.Contains(t, warning, "remote: ermite: mirror")
96+ assert.Contains(t, warning, "paused")
97+ token := c.signIn(t)
98+ page := (browser{h}).do("GET", "/proj", map[string]string{"Cookie": sessionCookie + "=" + token}, "")
99+ assert.Equal(t, 200, page.status)
100+ assert.Contains(t, page.body, "Mirror paused")
101+ assert.Contains(t, page.body, "ermite mirror status proj")
102+
103+ // Repair the remote, then force a normal (never forced) Git push to recover.
104+ latest := strings.TrimSpace(h.mustGit(wt, "rev-parse", "main"))
105+ h.mustGit(wt, "push", "--force", remote, "main") // deliberate remote repair, not mirror behavior
106+ run("sync", "proj", "--force").ok(t)
107+ assert.Equal(t, latest, strings.TrimSpace(h.mustGit(transport, "--git-dir="+remote, "rev-parse", "refs/heads/main")))
108+ assert.Contains(t, run("status", "proj").ok(t).stdout, "ok")
109+ run("sync", "--all", "--force").ok(t)
110+ var kinds []string
111+ for {
112+ select {
113+ case event := <-events:
114+ if event.EventKind() == "mirror.failed" || event.EventKind() == "mirror.recovered" {
115+ kinds = append(kinds, event.EventKind())
116+ }
117+ default:
118+ assert.Equal(t, []string{"mirror.failed", "mirror.recovered"}, kinds)
119+ return
120+ }
121+ }
122+}
A internal/app/cli_mirror_test.go +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestMirrorCLIArguments(t *testing.T) {
14+ for _, args := range [][]string{
15+ {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"},
16+ {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"},
17+ {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"},
18+ } {
19+ t.Run(strings.Join(args, " "), func(t *testing.T) {
20+ r := runCLI(t, nil, "", args...)
21+ require.Error(t, r.err)
22+ assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials")
23+ })
24+ }
25+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
26+ assert.Contains(t, r.stdout, "never force-pushes")
27+ assert.Contains(t, r.stdout, "lifts a pause")
28+}
29+
30+func TestMirrorCLIConfiguration(t *testing.T) {
31+ h := newSecretsCLI(t)
32+ run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
33+ run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t)
34+ r := run("config", "show", "--json").ok(t)
35+ g := decode[map[string]any](t, r.stdout)
36+ assert.Equal(t, true, g["enabled"])
37+ assert.Equal(t, true, g["all_branches"])
38+ assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"])
39+ run("config", "--enabled=false", "--all-branches=false").ok(t)
40+ g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout)
41+ assert.Equal(t, false, g["enabled"])
42+ assert.Equal(t, false, g["all_branches"])
43+ assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared")
44+ run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
45+ r = run("status", "proj", "--json").ok(t)
46+ s := decode[map[string]any](t, r.stdout)
47+ assert.Equal(t, "override", s["source"])
48+ assert.Equal(t, false, s["all_branches"])
49+ assert.Equal(t, "ok", s["state"])
50+ assert.Equal(t, "proj", s["repo"])
51+ assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
52+ assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
53+ run("remove", "proj").ok(t)
54+ run("exclude", "proj").ok(t)
55+ s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
56+ assert.Equal(t, "excluded", s["source"])
57+ run("include", "proj").ok(t)
58+ first := run("key", "show").ok(t)
59+ assert.Empty(t, first.stderr)
60+ firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string)
61+ rotated := run("key", "rotate").ok(t)
62+ assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint)
63+ assert.NotEqual(t, first.stdout, rotated.stdout)
64+ assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
65+ assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
66+ assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
67+}
M internal/app/cli_watch.go +14 -0
@@ -31,6 +31,9 @@ type jsonEvent struct {
3131 Comment *jsonComment `json:"comment,omitempty"`
3232 // Mergeability is set for a merge_request_mergeability event.
3333 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34+ // State and FailureClass are set for a mirror_failed event.
35+ State string `json:"state,omitempty"`
36+ FailureClass string `json:"failure_class,omitempty"`
3437 }
3538
3639 type jsonRefUpdate struct {
@@ -55,6 +58,8 @@ const (
5558 eventCommentDeleted = "comment_deleted"
5659 eventCommentResolved = "comment_resolved"
5760 eventMRMergeability = "merge_request_mergeability"
61+ eventMirrorFailed = "mirror_failed"
62+ eventMirrorRecovered = "mirror_recovered"
5863 )
5964
6065 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
100105 case *ermite.Event_MergeRequestMergeability:
101106 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102107 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
108+ case *ermite.Event_MirrorFailed:
109+ out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
110+ out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
111+ case *ermite.Event_MirrorRecovered:
112+ out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
103113 case *ermite.Event_Gap:
104114 out.Type = eventGap
105115 default:
@@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string {
134144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135145 case eventMRMergeability:
136146 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
147+ case eventMirrorFailed:
148+ detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
149+ case eventMirrorRecovered:
150+ detail = e.Repo
137151 case eventGap:
138152 detail = "events were lost, reload what you display"
139153 }
M internal/app/cli_watch_test.go +17 -0
@@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) {
266266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
267267 }
268268 }
269+
270+func TestMirrorWatchEvents(t *testing.T) {
271+ failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "")
272+ require.True(t, ok)
273+ assert.Equal(t, "mirror_failed", failed.Type)
274+ assert.Equal(t, "alpha", failed.Repo)
275+ assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)")
276+ raw, err := json.Marshal(failed)
277+ require.NoError(t, err)
278+ assert.Contains(t, string(raw), `"state":"paused"`)
279+ assert.Contains(t, string(raw), `"failure_class":"diverged"`)
280+ recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "")
281+ require.True(t, ok)
282+ assert.Equal(t, "mirror_recovered", recovered.Type)
283+ assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284+ assert.NotContains(t, eventLine(recovered), " \n")
285+}
M internal/app/forge.go +33 -1
@@ -26,6 +26,7 @@ import (
2626 "bnema.dev/ermite/internal/adapters/githttp"
2727 "bnema.dev/ermite/internal/adapters/githubapi"
2828 "bnema.dev/ermite/internal/adapters/githubraw"
29+ "bnema.dev/ermite/internal/adapters/gitpush"
2930 "bnema.dev/ermite/internal/adapters/gitremote"
3031 "bnema.dev/ermite/internal/adapters/httpserver"
3132 "bnema.dev/ermite/internal/adapters/kvstore"
@@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes
4950
5051 // options are the parts of the wiring that tests replace.
5152 type options struct {
53+ // pusher and mirrors override mirror transport/settings in local tests only.
54+ pusher ports.MirrorPusher
55+ mirrors func(*usecase.MirrorsConfig)
5256 // hookExe is the executable the hook scripts exec; empty means this
5357 // process's own executable.
5458 hookExe string
@@ -151,6 +155,8 @@ type forge struct {
151155 CI *usecase.CI
152156 // Settings are the secrets, variables and environments of CI.
153157 Settings *usecase.CISettings
158+ // Mirrors exposes Warning and Badge for the hook server and web composition.
159+ Mirrors *usecase.Mirrors
154160 // OIDC is the provider of ID tokens, disabled without a secrets key.
155161 OIDC *usecase.OIDC
156162 API *rpc.API
@@ -274,6 +280,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
274280 }
275281 sealer = box
276282 }
283+ pusher := opts.pusher
284+ if pusher == nil {
285+ pusher = gitpush.New(gitpush.Config{
286+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287+ KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288+ TempDir: filepath.Join(cfg.DataDir, "run", "mirror"),
289+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290+ })
291+ }
292+ mirrorCfg := usecase.MirrorsConfig{
293+ Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294+ Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295+ }
296+ if opts.mirrors != nil {
297+ opts.mirrors(&mirrorCfg)
298+ }
299+ f.Mirrors = usecase.NewMirrors(mirrorCfg)
300+ f.Pushes.WithMirror(f.Mirrors)
301+ f.Repos.WithMirror(f.Mirrors)
302+ f.hooks.WithMirrorWarner(f.Mirrors)
303+
277304 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
278305 if err != nil {
279306 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
@@ -342,7 +369,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
342369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
343370 })
344371 f.Mergeability.Start()
345- f.MergeRequests.WithVerifier(f.Signatures)
372+ f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
346373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
347374 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
348375 })
@@ -400,6 +427,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
400427 }
401428 f.API = rpc.New(rpc.Config{
402429 Auth: f.Auth,
430+ Mirrors: f.Mirrors,
403431 Repos: f.Repos,
404432 Tokens: f.Tokens,
405433 Browse: f.Browse,
@@ -489,6 +517,7 @@ func (f *forge) Run(ctx context.Context) error {
489517 // outlives the HTTP server: requests still in flight while it drains are
490518 // pushes whose hooks need it. A failure of either stops both.
491519 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
520+ f.Mirrors.Start()
492521 ctx, cancel := context.WithCancel(ctx)
493522 defer cancel()
494523 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
@@ -517,6 +546,9 @@ func (f *forge) Close(ctx context.Context) {
517546 if f.MergeRequests != nil {
518547 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
519548 }
549+ if f.Mirrors != nil {
550+ f.Mirrors.Close(ctx)
551+ }
520552 if f.CI != nil {
521553 f.CI.Close(ctx)
522554 }
M internal/app/root.go +1 -1
@@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3030 }
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
33- newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
33+ newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
M internal/app/web.go +1 -0
@@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5858 Runs: f.CI,
5959 Artifacts: artifacts,
6060 Events: f.EventLog,
61+ Mirrors: f.Mirrors,
6162 Owner: cfg.Owner,
6263 PublicURL: cfg.PublicURL,
6364 SourceURL: cfg.SourceURL,
M internal/domain/event.go +22 -0
@@ -304,3 +304,25 @@ type EventGap struct{}
304304
305305 // EventKind implements Event.
306306 func (EventGap) EventKind() string { return "gap" }
307+
308+// MirrorFailed is published when a mirror enters failing or paused (transitions only).
309+type MirrorFailed struct {
310+ Repo RepoName
311+ State MirrorState
312+ Class MirrorFailureClass
313+ At time.Time
314+ Visibility Visibility
315+ CreatedAt time.Time
316+}
317+
318+func (MirrorFailed) EventKind() string { return "mirror.failed" }
319+
320+// MirrorRecovered is published when a failing or paused mirror succeeds again.
321+type MirrorRecovered struct {
322+ Repo RepoName
323+ At time.Time
324+ Visibility Visibility
325+ CreatedAt time.Time
326+}
327+
328+func (MirrorRecovered) EventKind() string { return "mirror.recovered" }
A internal/domain/mirror.go +254 -0
@@ -0,0 +1,254 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "net"
8+ "net/url"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode"
13+)
14+
15+const (
16+ MirrorMinInterval = 5 * time.Minute // densest cron allowed
17+ MirrorManualInterval = 60 * time.Second // min gap between manual syncs (not --force)
18+ MirrorRepoPlaceholder = "{repo}"
19+)
20+
21+// MirrorGlobalConfig is the server-wide mirror policy.
22+type MirrorGlobalConfig struct {
23+ Enabled bool
24+ URLTemplate string // ssh URL containing MirrorRepoPlaceholder; "" allowed while disabled
25+ Cron string // 5-field cron, UTC; "" allowed while disabled
26+ AllBranches bool
27+ UpdatedAt time.Time
28+}
29+
30+// MirrorRepoConfig is the per-repository entry: an override (URL != "") or an exclusion.
31+type MirrorRepoConfig struct {
32+ Repo RepoName
33+ URL string // "" means no override
34+ Cron string // "" means inherit global cron
35+ AllBranches *bool // nil means inherit
36+ Excluded bool // opt out of the global template; ignored when URL != ""
37+ UpdatedAt time.Time
38+}
39+type MirrorSource string
40+
41+const (
42+ MirrorSourceNone MirrorSource = "disabled" // nothing applies
43+ MirrorSourceGlobal MirrorSource = "global"
44+ MirrorSourceOverride MirrorSource = "override"
45+ MirrorSourceExcluded MirrorSource = "excluded"
46+)
47+
48+// MirrorTarget is what one repository is mirrored to after resolution.
49+type MirrorTarget struct {
50+ Repo RepoName
51+ Enabled bool
52+ URL string
53+ Cron Cron // zero when !Enabled
54+ CronSpec string
55+ AllBranches bool
56+ Source MirrorSource
57+}
58+
59+func mirrorURLParts(s string) (host, path string, err error) {
60+ bad := func() (string, string, error) {
61+ return "", "", fmt.Errorf("%w: mirror url must be an SSH host and repository path without credentials", ErrInvalid)
62+ }
63+ if strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) {
64+ return bad()
65+ }
66+ if strings.HasPrefix(s, "ssh://") {
67+ u, e := url.Parse(s)
68+ if e != nil || u.Hostname() == "" || u.RawQuery != "" || u.Fragment != "" {
69+ return bad()
70+ }
71+ if u.User != nil {
72+ if _, ok := u.User.Password(); ok {
73+ return bad()
74+ }
75+ if u.User.Username() == "" || strings.HasPrefix(u.User.Username(), "-") {
76+ return bad()
77+ }
78+ }
79+ if p := u.Port(); p != "" {
80+ n, e := strconv.Atoi(p)
81+ if e != nil || n < 1 || n > 65535 {
82+ return bad()
83+ }
84+ }
85+ host = u.Host
86+ path = strings.TrimPrefix(u.Path, "/")
87+ } else {
88+ if strings.Contains(s, "://") {
89+ return bad()
90+ }
91+ var ok bool
92+ host, path, ok = strings.Cut(s, ":")
93+ if !ok {
94+ return bad()
95+ }
96+ if strings.Contains(host, "@") {
97+ user, h, _ := strings.Cut(host, "@")
98+ if user == "" || strings.HasPrefix(user, "-") || strings.ContainsAny(user, ":/@") {
99+ return bad()
100+ }
101+ host = h
102+ }
103+ if strings.ContainsAny(host, "/@:") {
104+ return bad()
105+ }
106+ }
107+ if host == "" || strings.HasPrefix(host, "-") || path == "" || strings.HasPrefix(path, "-") || strings.Contains(path, "@") {
108+ return bad()
109+ }
110+ // Reject malformed bracketed addresses as well as empty hosts.
111+ if strings.HasPrefix(host, "[") {
112+ h := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
113+ if x, _, e := net.SplitHostPort(host); e == nil {
114+ h = strings.Trim(x, "[]")
115+ }
116+ if net.ParseIP(h) == nil {
117+ return bad()
118+ }
119+ }
120+ return host, path, nil
121+}
122+
123+// ValidateMirrorURL accepts "git@host:path" (scp-like, user optional, no ':' port) and
124+// "ssh://[user@]host[:port]/path". Anything else, an empty host, control chars, or a path
125+// starting with "-" is "%w: mirror url ..." with ErrInvalid. URL is never logged in full
126+// elsewhere: use RedactMirrorURL.
127+func ValidateMirrorURL(s string) error { _, _, err := mirrorURLParts(s); return err }
128+func RedactMirrorURL(s string) string {
129+ h, p, e := mirrorURLParts(s)
130+ if e != nil {
131+ return "[invalid mirror URL]"
132+ }
133+ return h + ":" + p
134+}
135+
136+// ExpandMirrorURL replaces MirrorRepoPlaceholder with repo (as a path segment, not escaped
137+// beyond RepoName's own charset). Returns ErrInvalid when the template has no placeholder.
138+func ExpandMirrorURL(template string, repo RepoName) (string, error) {
139+ if !strings.Contains(template, MirrorRepoPlaceholder) {
140+ return "", fmt.Errorf("%w: mirror url template needs {repo}", ErrInvalid)
141+ }
142+ return strings.ReplaceAll(template, MirrorRepoPlaceholder, repo.String()), nil
143+}
144+func mirrorCron(spec string) (Cron, error) {
145+ c, e := ParseCron(spec)
146+ if e != nil {
147+ return c, e
148+ }
149+ if gap := CronMinGap(c); gap > 0 && gap < MirrorMinInterval {
150+ return Cron{}, fmt.Errorf("%w: cron interval must be at least five minutes", ErrInvalid)
151+ }
152+ return c, nil
153+}
154+
155+// ValidateMirrorGlobalConfig / ValidateMirrorRepoConfig return InvalidFieldsError with fields
156+// "url_template", "cron", "url": cron parsed with ParseCron and checked by CronMinGap >= MirrorMinInterval;
157+// url_template must contain the placeholder; when Enabled both are required.
158+func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {
159+ return ValidateMirrorGlobalConfigWith(g, ValidateMirrorURL)
160+}
161+
162+// ValidateMirrorGlobalConfigWith is ValidateMirrorGlobalConfig with another URL check, for
163+// deployments and tests that accept more than SSH URLs.
164+func ValidateMirrorGlobalConfigWith(g MirrorGlobalConfig, validateURL func(string) error) error {
165+ var fields []FieldViolation
166+ if g.Enabled || g.URLTemplate != "" {
167+ repo, _ := ParseRepoName("repo")
168+ u, e := ExpandMirrorURL(g.URLTemplate, repo)
169+ if e == nil {
170+ e = validateURL(u)
171+ }
172+ if e != nil {
173+ fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})
174+ }
175+ }
176+ if g.Enabled || g.Cron != "" {
177+ if _, e := mirrorCron(g.Cron); e != nil {
178+ fields = append(fields, FieldViolation{"cron", e.Error()})
179+ }
180+ }
181+ if len(fields) > 0 {
182+ return &InvalidFieldsError{Fields: fields}
183+ }
184+ return nil
185+}
186+func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {
187+ return ValidateMirrorRepoConfigWith(r, ValidateMirrorURL)
188+}
189+
190+// ValidateMirrorRepoConfigWith is ValidateMirrorRepoConfig with another URL check.
191+func ValidateMirrorRepoConfigWith(r MirrorRepoConfig, validateURL func(string) error) error {
192+ var fields []FieldViolation
193+ if r.URL != "" {
194+ if e := validateURL(r.URL); e != nil {
195+ fields = append(fields, FieldViolation{"url", e.Error()})
196+ }
197+ }
198+ if r.Cron != "" {
199+ if _, e := mirrorCron(r.Cron); e != nil {
200+ fields = append(fields, FieldViolation{"cron", e.Error()})
201+ }
202+ }
203+ if len(fields) > 0 {
204+ return &InvalidFieldsError{Fields: fields}
205+ }
206+ return nil
207+}
208+
209+// ResolveMirror: override wins even when global is disabled; else global, unless excluded.
210+func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (MirrorTarget, error) {
211+ return ResolveMirrorWith(g, r, repo, ValidateMirrorURL)
212+}
213+
214+// ResolveMirrorWith is ResolveMirror with another URL check.
215+func ResolveMirrorWith(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName, validateURL func(string) error) (MirrorTarget, error) {
216+ t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}
217+ if r != nil && r.URL != "" {
218+ t.URL = r.URL
219+ t.Source = MirrorSourceOverride
220+ } else if r != nil && r.Excluded {
221+ t.Source = MirrorSourceExcluded
222+ return t, nil
223+ } else if g.Enabled {
224+ var e error
225+ t.URL, e = ExpandMirrorURL(g.URLTemplate, repo)
226+ if e != nil {
227+ return t, e
228+ }
229+ t.Source = MirrorSourceGlobal
230+ } else {
231+ return t, nil
232+ }
233+ t.CronSpec = g.Cron
234+ t.AllBranches = g.AllBranches
235+ if r != nil {
236+ if r.Cron != "" {
237+ t.CronSpec = r.Cron
238+ }
239+ if r.AllBranches != nil {
240+ t.AllBranches = *r.AllBranches
241+ }
242+ }
243+ if e := validateURL(t.URL); e != nil {
244+ return t, e
245+ }
246+ // Mutation boundaries validate the minimum gap; resolution only parses.
247+ c, e := ParseCron(t.CronSpec)
248+ if e != nil {
249+ return t, e
250+ }
251+ t.Cron = c
252+ t.Enabled = true
253+ return t, nil
254+}
A internal/domain/mirror_cron.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package domain
3+
4+import (
5+ "sync"
6+ "time"
7+)
8+
9+// cronGapCache memoizes CronMinGap per parsed entry: a scan covers two years of minutes.
10+const cronGapCacheMax = 256
11+
12+var cronGapCache = struct {
13+ sync.Mutex
14+ m map[Cron]time.Duration
15+}{m: make(map[Cron]time.Duration)}
16+
17+// CronMinGap is the smallest gap between two firings, 0 when the entry fires fewer than twice.
18+// It scans a leap year, the following non-leap year and the January after, so that Feb 29,
19+// year-end and Feb 28/Mar 1 neighbours are all compared.
20+func CronMinGap(c Cron) time.Duration {
21+ cronGapCache.Lock()
22+ gap, ok := cronGapCache.m[c]
23+ cronGapCache.Unlock()
24+ if ok {
25+ return gap
26+ }
27+ gap = scanCronMinGap(c)
28+ cronGapCache.Lock()
29+ if len(cronGapCache.m) >= cronGapCacheMax {
30+ clear(cronGapCache.m)
31+ }
32+ cronGapCache.m[c] = gap
33+ cronGapCache.Unlock()
34+ return gap
35+}
36+
37+func scanCronMinGap(c Cron) time.Duration {
38+ start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) // leap
39+ end := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) // 2025 is not leap; include Jan 2026
40+ var prev time.Time
41+ var gap time.Duration
42+ for t := start; t.Before(end); t = t.Add(time.Minute) {
43+ if !c.dayMatches(t) {
44+ t = t.Truncate(24 * time.Hour).Add(24*time.Hour - time.Minute)
45+ continue
46+ }
47+ if c.Matches(t) {
48+ if !prev.IsZero() {
49+ d := t.Sub(prev)
50+ if gap == 0 || d < gap {
51+ gap = d
52+ }
53+ }
54+ prev = t
55+ }
56+ }
57+ return gap
58+}
59+
60+// dayMatches is the day-level part of Matches (month, day of month, day of week), UTC.
61+func (c Cron) dayMatches(t time.Time) bool {
62+ t = t.UTC()
63+ if c.month&(1<<uint(t.Month())) == 0 {
64+ return false
65+ }
66+ domOK := c.dom&(1<<uint(t.Day())) != 0
67+ dowOK := c.dow&(1<<uint(t.Weekday())) != 0
68+ if !c.domStar && !c.dowStar {
69+ return domOK || dowOK
70+ }
71+ return domOK && dowOK
72+}
73+
74+// nextCronHorizon bounds NextCron: a year of minutes.
75+const nextCronHorizon = 366 * 24 * time.Hour
76+
77+// NextCron is the first firing after t, within a year. UTC schedules skip the days that cannot match.
78+func NextCron(s CronSchedule, t time.Time) (time.Time, bool) {
79+ end := t.Add(nextCronHorizon)
80+ utc := s.Loc == nil || s.Loc == time.UTC
81+ for n := t.Truncate(time.Minute).Add(time.Minute); !n.After(end); n = n.Add(time.Minute) {
82+ if utc && !s.Cron.dayMatches(n) {
83+ n = n.Truncate(24 * time.Hour).Add(24*time.Hour - time.Minute)
84+ continue
85+ }
86+ if s.Due(n) {
87+ return n, true
88+ }
89+ }
90+ return time.Time{}, false
91+}
A internal/domain/mirror_key.go +39 -0
@@ -0,0 +1,39 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "crypto/ed25519"
7+ "crypto/sha256"
8+ "encoding/base64"
9+ "encoding/binary"
10+ "net/url"
11+ "time"
12+)
13+
14+const MirrorKeyPath = "mirror/key" // KV key and secretbox AAD of the sealed seed
15+type MirrorPublicKey struct {
16+ AuthorizedKey string // "ssh-ed25519 AAAA… ermite-mirror@<host>"
17+ Fingerprint string // "SHA256:<base64 no padding>"
18+ CreatedAt time.Time
19+}
20+
21+func MirrorKeyComment(publicURL string) string {
22+ host := "localhost"
23+ if u, e := url.Parse(publicURL); e == nil && u.Hostname() != "" {
24+ host = u.Hostname()
25+ }
26+ return "ermite-mirror@" + host
27+}
28+
29+// MirrorPublicKeyOf formats an ed25519 public key (wire encoding + base64 done with stdlib).
30+func MirrorPublicKeyOf(pub ed25519.PublicKey, host string, createdAt time.Time) MirrorPublicKey {
31+ wire := make([]byte, 4+len("ssh-ed25519")+4+len(pub))
32+ binary.BigEndian.PutUint32(wire, uint32(len("ssh-ed25519")))
33+ copy(wire[4:], "ssh-ed25519")
34+ offset := 4 + len("ssh-ed25519")
35+ binary.BigEndian.PutUint32(wire[offset:], uint32(len(pub)))
36+ copy(wire[offset+4:], pub)
37+ sum := sha256.Sum256(wire)
38+ return MirrorPublicKey{AuthorizedKey: "ssh-ed25519 " + base64.StdEncoding.EncodeToString(wire) + " " + MirrorKeyComment("https://"+host), Fingerprint: "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]), CreatedAt: createdAt}
39+}
A internal/domain/mirror_refs.go +57 -0
@@ -0,0 +1,57 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "sort"
7+ "strings"
8+ "time"
9+ "unicode/utf8"
10+)
11+
12+type MirrorRef struct {
13+ Name string
14+ ID ObjectID
15+} // ID = Ref.ID (tag object for annotated tags)
16+// MirrorSnapshot is the last pushed set, persisted; Equal makes the sync a no-op.
17+type MirrorSnapshot struct {
18+ URL string
19+ Refs []MirrorRef
20+ AllBranches bool
21+ At time.Time
22+}
23+
24+// MirrorRefspecs selects refs/heads/<default> (or every refs/heads/* with allBranches) and
25+// refs/tags/*, from the refs of RepoReader.ListRefs (HEAD skipped, unborn default skipped).
26+// Names are plain refs; the caller builds "<ref>:<ref>" (never "+", never a delete).
27+// Refs whose name is not valid UTF-8 are skipped: they cannot be stored in the snapshot.
28+// Returned sorted by ref name.
29+func MirrorRefspecs(refs []Ref, defaultBranch string, allBranches bool) []MirrorRef {
30+ var out []MirrorRef
31+ for _, r := range refs {
32+ if r.ID.IsZero() || r.ID.String() == ZeroOID || !utf8.ValidString(r.Name) {
33+ continue
34+ }
35+ if strings.HasPrefix(r.Name, "refs/tags/") || strings.HasPrefix(r.Name, "refs/heads/") && (allBranches || r.Name == "refs/heads/"+defaultBranch) {
36+ out = append(out, MirrorRef{Name: r.Name, ID: r.ID})
37+ }
38+ }
39+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
40+ return out
41+}
42+func (s MirrorSnapshot) Equal(url string, refs []MirrorRef, allBranches bool) bool {
43+ if s.URL != url || s.AllBranches != allBranches || len(s.Refs) != len(refs) {
44+ return false
45+ }
46+ counts := make(map[MirrorRef]int, len(refs))
47+ for _, r := range s.Refs {
48+ counts[r]++
49+ }
50+ for _, r := range refs {
51+ if counts[r] == 0 {
52+ return false
53+ }
54+ counts[r]--
55+ }
56+ return true
57+}
A internal/domain/mirror_state.go +202 -0
@@ -0,0 +1,202 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "strings"
8+ "time"
9+)
10+
11+type MirrorState string
12+
13+const (
14+ MirrorDisabled MirrorState = "disabled"
15+ MirrorOK MirrorState = "ok"
16+ MirrorFailing MirrorState = "failing"
17+ MirrorPaused MirrorState = "paused"
18+)
19+
20+type MirrorFailureClass string
21+
22+const (
23+ MirrorClassNone MirrorFailureClass = ""
24+ MirrorClassDiverged MirrorFailureClass = "diverged" // non-fast-forward / fetch-first -> paused
25+ MirrorClassAuthDenied MirrorFailureClass = "auth_denied" // "Permission denied (publickey)" -> paused
26+ MirrorClassRemoteNotFound MirrorFailureClass = "remote_not_found" // "Repository not found", "does not appear to be a git repository" -> paused
27+ MirrorClassHostKey MirrorFailureClass = "host_key" // known_hosts mismatch -> paused
28+ MirrorClassTransient MirrorFailureClass = "transient" // timeout, network, exit without a known marker -> backoff
29+ MirrorClassInternal MirrorFailureClass = "internal" // no key, no secrets key, local git error -> backoff
30+)
31+
32+// MirrorError is what the transport returns: Class decides the transition, Detail (capped,
33+// URL-redacted stderr tail) is shown to admins only. Unwrap() yields ErrUnavailable for
34+// transient, ErrPreconditionFailed for the paused classes.
35+type MirrorError struct {
36+ Class MirrorFailureClass
37+ Detail string
38+}
39+
40+// MirrorStatus is the persisted per-repository state.
41+type MirrorStatus struct {
42+ Repo RepoName
43+ State MirrorState
44+ Class MirrorFailureClass
45+ Reason string // MirrorReason(...) output, human
46+ Detail string // last error detail (admin only)
47+ ConsecutiveFailures int
48+ LastAttemptAt time.Time
49+ LastSuccessAt time.Time
50+ NextRetryAt time.Time // zero unless Failing
51+ PausedAt time.Time
52+ UpdatedAt time.Time
53+}
54+type MirrorOutcome struct {
55+ Err *MirrorError
56+ NoOp bool
57+} // Err nil == success
58+type MirrorTransition struct {
59+ Status MirrorStatus
60+ FailedEvent bool // State moved into Failing or Paused from OK/Disabled: once per incident
61+ Recovered bool // State moved from Failing/Paused to OK
62+}
63+
64+const (
65+ MirrorBackoffBase = 5 * time.Minute
66+ MirrorBackoffCap = 6 * time.Hour
67+ MirrorPauseAfter = 5 // consecutive transient failures
68+)
69+
70+func (e *MirrorError) Error() string { return fmt.Sprintf("mirror %s: %s", e.Class, e.Detail) }
71+func mirrorFatal(c MirrorFailureClass) bool {
72+ return c == MirrorClassDiverged || c == MirrorClassAuthDenied || c == MirrorClassRemoteNotFound || c == MirrorClassHostKey
73+}
74+func (e *MirrorError) Unwrap() error {
75+ if mirrorFatal(e.Class) {
76+ return ErrPreconditionFailed
77+ }
78+ return ErrUnavailable
79+}
80+
81+// ClassifyMirrorOutput maps git/ssh stderr to a class (table of substrings, tested).
82+func ClassifyMirrorOutput(stderr string, exitErr error, timedOut bool) MirrorFailureClass {
83+ if timedOut {
84+ return MirrorClassTransient
85+ }
86+ s := strings.ToLower(stderr)
87+ for _, line := range strings.Split(s, "\n") {
88+ if i := strings.Index(line, "permission denied ("); i >= 0 {
89+ methods, _, closed := strings.Cut(line[i+len("permission denied ("):], ")")
90+ if closed && strings.Contains(methods, "publickey") {
91+ return MirrorClassAuthDenied
92+ }
93+ }
94+ if strings.Contains(line, "permission to ") && strings.Contains(line, " denied") {
95+ return MirrorClassAuthDenied
96+ }
97+ }
98+ for _, entry := range []struct {
99+ class MirrorFailureClass
100+ markers []string
101+ }{
102+ {MirrorClassHostKey, []string{"host key verification failed", "remote host identification has changed", "host key has changed"}},
103+ {MirrorClassAuthDenied, []string{"permission denied (publickey)", "authentication failed"}},
104+ {MirrorClassRemoteNotFound, []string{"repository not found", "does not appear to be a git repository"}},
105+ {MirrorClassDiverged, []string{"non-fast-forward", "fetch first", "fetch-first"}},
106+ } {
107+ for _, m := range entry.markers {
108+ if strings.Contains(s, m) {
109+ return entry.class
110+ }
111+ }
112+ }
113+ if exitErr != nil {
114+ return MirrorClassTransient
115+ }
116+ return MirrorClassNone
117+}
118+func MirrorBackoff(n int) time.Duration {
119+ d := MirrorBackoffBase
120+ for i := 1; i < n && d < MirrorBackoffCap; i++ {
121+ d *= 2
122+ }
123+ return min(d, MirrorBackoffCap)
124+}
125+
126+// MirrorDisable/MirrorReset are the config-change transitions: reset lifts Paused/Failing to OK
127+// with counters cleared (config changed); disable -> Disabled. sync --force does not reset: the
128+// attempt is judged against the previous status so a recovery still reports Recovered.
129+func MirrorReset(p MirrorStatus, now time.Time) MirrorStatus {
130+ p.State = MirrorOK
131+ p.Class = MirrorClassNone
132+ p.Reason = ""
133+ p.Detail = ""
134+ p.ConsecutiveFailures = 0
135+ p.NextRetryAt = time.Time{}
136+ p.PausedAt = time.Time{}
137+ p.UpdatedAt = now
138+ return p
139+}
140+func MirrorDisable(p MirrorStatus, now time.Time) MirrorStatus {
141+ p = MirrorReset(p, now)
142+ p.State = MirrorDisabled
143+ return p
144+}
145+
146+// ApplyMirrorOutcome is the single transition function (pure):
147+//
148+// success -> OK, counters cleared, LastSuccessAt=now (NoOp also sets LastSuccessAt).
149+// fatal class (Diverged/AuthDenied/RemoteNotFound/HostKey) -> Paused at once.
150+// Transient/Internal -> Failing, n++, NextRetryAt=now+MirrorBackoff(n); n>=MirrorPauseAfter -> Paused.
151+// A Paused status receiving an outcome was synced with force: it is judged like any other.
152+func ApplyMirrorOutcome(prev MirrorStatus, out MirrorOutcome, now time.Time) MirrorTransition {
153+ p := MirrorReset(prev, now)
154+ p.LastAttemptAt = now
155+ if out.Err == nil {
156+ p.LastSuccessAt = now
157+ return MirrorTransition{Status: p, Recovered: prev.State == MirrorFailing || prev.State == MirrorPaused}
158+ }
159+ p.Class = out.Err.Class
160+ p.Detail = out.Err.Detail
161+ p.ConsecutiveFailures = prev.ConsecutiveFailures + 1
162+ p.State = MirrorFailing
163+ if mirrorFatal(p.Class) || p.ConsecutiveFailures >= MirrorPauseAfter {
164+ p.State = MirrorPaused
165+ p.PausedAt = now
166+ if prev.State == MirrorPaused && !prev.PausedAt.IsZero() {
167+ p.PausedAt = prev.PausedAt
168+ }
169+ } else {
170+ p.NextRetryAt = now.Add(MirrorBackoff(p.ConsecutiveFailures))
171+ }
172+ p.Reason = MirrorReason(p)
173+ return MirrorTransition{Status: p, FailedEvent: prev.State != MirrorFailing && prev.State != MirrorPaused}
174+}
175+
176+// MirrorReason is the human sentence of a status, e.g.
177+//
178+// diverged: "the remote has commits this repository does not: push is never forced; fix the remote and run `ermite mirror sync <repo> --force`"
179+// auth_denied: "the remote refused the mirror key: add the output of `ermite mirror key show` to the remote"
180+// remote_not_found: "the remote repository does not exist or is not reachable with this key"
181+// host_key: "the host key of the remote changed: check <data dir>/mirror_known_hosts"
182+// transient: "the last %d attempts failed (%s); next retry %s"
183+// paused after transient: "paused after %d consecutive failures; run `ermite mirror sync <repo> --force`"
184+func MirrorReason(s MirrorStatus) string {
185+ if s.State != MirrorFailing && s.State != MirrorPaused {
186+ return ""
187+ }
188+ switch s.Class {
189+ case MirrorClassDiverged:
190+ return "the remote has commits this repository does not: push is never forced; fix the remote and run `ermite mirror sync <repo> --force`"
191+ case MirrorClassAuthDenied:
192+ return "the remote refused the mirror key: add the output of `ermite mirror key show` to the remote"
193+ case MirrorClassRemoteNotFound:
194+ return "the remote repository does not exist or is not reachable with this key"
195+ case MirrorClassHostKey:
196+ return "the host key of the remote changed: check <data dir>/mirror_known_hosts"
197+ }
198+ if s.State == MirrorPaused {
199+ return fmt.Sprintf("paused after %d consecutive failures; run `ermite mirror sync <repo> --force`", s.ConsecutiveFailures)
200+ }
201+ return fmt.Sprintf("the last %d attempts failed (%s); next retry %s", s.ConsecutiveFailures, s.Class, s.NextRetryAt.UTC().Format(time.RFC3339))
202+}
A internal/domain/mirror_test.go +302 -0
@@ -0,0 +1,302 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+package domain
3+
4+import (
5+ "crypto/ed25519"
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "encoding/binary"
9+ "errors"
10+ "strings"
11+ "testing"
12+ "time"
13+)
14+
15+func TestMirrorURL(t *testing.T) {
16+ for _, tt := range []struct {
17+ s string
18+ ok bool
19+ }{
20+ {"git@host:acme/repo", true}, {"host:repo", true}, {"ssh://git@host:2222/acme/repo", true}, {"ssh://[::1]/repo", true},
21+ {"https://host/repo", false}, {"file:///repo", false}, {"git@host:-path", false}, {"ssh://host/-path", false}, {":repo", false}, {"ssh:///repo", false}, {"ssh://git:secret@host/repo", false}, {"user:secret@host:repo", false}, {"host:repo\n", false}, {"ssh://host:0/repo", false},
22+ {"-oProxyCommand=x@h:p", false}, {"-oProxyCommand=x@h:p/q", false}, {"ssh://-ofoo@h/p", false}, {"ssh://-oHost/p", false}, {"-h:repo", false}, {"git@-h:repo", false}, {"ssh://git@-h/repo", false},
23+ } {
24+ t.Run(tt.s, func(t *testing.T) {
25+ e := ValidateMirrorURL(tt.s)
26+ if (e == nil) != tt.ok {
27+ t.Fatalf("error %v", e)
28+ }
29+ if e != nil && !errors.Is(e, ErrInvalid) {
30+ t.Fatal(e)
31+ }
32+ })
33+ }
34+ if got := RedactMirrorURL("ssh://git@host:2222/acme/repo"); got != "host:2222:acme/repo" {
35+ t.Fatal(got)
36+ }
37+ if strings.Contains(RedactMirrorURL("ssh://git:secret@host/repo"), "secret") {
38+ t.Fatal("credential leak")
39+ }
40+}
41+func TestMirrorResolution(t *testing.T) {
42+ repo, _ := ParseRepoName("demo")
43+ off := false
44+ for _, enabled := range []bool{false, true} {
45+ for _, kind := range []string{"none", "exclude", "override"} {
46+ t.Run(kind+map[bool]string{true: "on", false: "off"}[enabled], func(t *testing.T) {
47+ g := MirrorGlobalConfig{Enabled: enabled, URLTemplate: "git@host:org/{repo}", Cron: "*/5 * * * *", AllBranches: true}
48+ var r *MirrorRepoConfig
49+ want := MirrorSourceNone
50+ if enabled {
51+ want = MirrorSourceGlobal
52+ }
53+ if kind == "exclude" {
54+ r = &MirrorRepoConfig{Excluded: true}
55+ want = MirrorSourceExcluded
56+ }
57+ if kind == "override" {
58+ r = &MirrorRepoConfig{URL: "git@other:demo", Cron: "0 3 * * *", AllBranches: &off, Excluded: true}
59+ want = MirrorSourceOverride
60+ }
61+ got, e := ResolveMirror(g, r, repo)
62+ if e != nil || got.Source != want || got.Enabled != (want == MirrorSourceGlobal || want == MirrorSourceOverride) {
63+ t.Fatalf("%+v %v", got, e)
64+ }
65+ if want == MirrorSourceOverride && (got.AllBranches || got.CronSpec != "0 3 * * *") {
66+ t.Fatal(got)
67+ }
68+ if want == MirrorSourceGlobal && got.URL != "git@host:org/demo" {
69+ t.Fatal(got)
70+ }
71+ })
72+ }
73+ }
74+ if _, e := ExpandMirrorURL("host:path", repo); !errors.Is(e, ErrInvalid) {
75+ t.Fatal(e)
76+ }
77+}
78+func TestMirrorCronValidation(t *testing.T) {
79+ for _, tt := range []struct {
80+ spec string
81+ gap time.Duration
82+ valid bool
83+ }{{"*/5 * * * *", 5 * time.Minute, true}, {"*/4 * * * *", 4 * time.Minute, false}, {"0 3 * * *", 24 * time.Hour, true}, {"0 0 29 2 *", 0, true}, {"bad", 0, false},
84+ {"0,59 0,23 1,31 1,12 *", time.Minute, false}, // 31 Dec 23:59 and 1 Jan 00:00
85+ {"0,59 0,23 1,28 2,3 *", time.Minute, false}, // 28 Feb 23:59 and 1 Mar 00:00 of a non-leap year
86+ {"0,59 0,23 29 2 *", 59 * time.Minute, true},
87+ } {
88+ t.Run(tt.spec, func(t *testing.T) {
89+ c, e := ParseCron(tt.spec)
90+ if e == nil && CronMinGap(c) != tt.gap {
91+ t.Fatal(CronMinGap(c))
92+ }
93+ g := MirrorGlobalConfig{Enabled: true, URLTemplate: "git@host:{repo}", Cron: tt.spec}
94+ if (ValidateMirrorGlobalConfig(g) == nil) != tt.valid {
95+ t.Fatal("global validation")
96+ }
97+ if (ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: tt.spec}) == nil) != tt.valid {
98+ t.Fatal("repo validation")
99+ }
100+ })
101+ }
102+ for _, g := range []MirrorGlobalConfig{{}, {URLTemplate: "https://host/{repo}"}, {Enabled: true}, {URLTemplate: "host:path"}} {
103+ e := ValidateMirrorGlobalConfig(g)
104+ if g == (MirrorGlobalConfig{}) {
105+ if e != nil {
106+ t.Fatal(e)
107+ }
108+ } else {
109+ var fields *InvalidFieldsError
110+ if !errors.As(e, &fields) {
111+ t.Fatalf("%v", e)
112+ }
113+ }
114+ }
115+}
116+func TestMirrorNextCron(t *testing.T) {
117+ loc, _ := time.LoadLocation("America/New_York")
118+ c, _ := ParseCron("30 2 * * *")
119+ start := time.Date(2024, 3, 10, 1, 59, 30, 0, loc)
120+ got, ok := NextCron(CronSchedule{Cron: c, Loc: loc}, start)
121+ want := time.Date(2024, 3, 10, 3, 0, 0, 0, loc)
122+ if !ok || !got.Equal(want) {
123+ t.Fatal(got, ok)
124+ }
125+ c, _ = ParseCron("* * * * *")
126+ got, ok = NextCron(CronSchedule{Cron: c}, start)
127+ if !ok || !got.Equal(start.Truncate(time.Minute).Add(time.Minute)) {
128+ t.Fatal(got)
129+ }
130+ if _, ok := NextCron(CronSchedule{}, start); ok {
131+ t.Fatal("empty cron fired")
132+ }
133+ // A wall clock repeated by a fall-back transition fires once.
134+ c, _ = ParseCron("30 1 * * *")
135+ got, ok = NextCron(CronSchedule{Cron: c, Loc: loc}, time.Date(2024, 11, 3, 5, 30, 0, 0, time.UTC))
136+ if want := time.Date(2024, 11, 4, 1, 30, 0, 0, loc); !ok || !got.Equal(want) {
137+ t.Fatal(got, ok)
138+ }
139+ // A rare entry is found within a year, not beyond.
140+ c, _ = ParseCron("0 0 29 2 *")
141+ got, ok = NextCron(CronSchedule{Cron: c}, time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC))
142+ if ok {
143+ t.Fatal("Feb 29 is more than a year away", got)
144+ }
145+ got, ok = NextCron(CronSchedule{Cron: c}, time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC))
146+ if want := time.Date(2028, 2, 29, 0, 0, 0, 0, time.UTC); !ok || !got.Equal(want) {
147+ t.Fatal(got, ok)
148+ }
149+}
150+func TestMirrorTransitions(t *testing.T) {
151+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
152+ for _, class := range []MirrorFailureClass{MirrorClassDiverged, MirrorClassAuthDenied, MirrorClassRemoteNotFound, MirrorClassHostKey, MirrorClassTransient, MirrorClassInternal} {
153+ t.Run(string(class), func(t *testing.T) {
154+ p := MirrorStatus{State: MirrorOK}
155+ fatal := mirrorFatal(class)
156+ for n := 1; n <= 6; n++ {
157+ tr := ApplyMirrorOutcome(p, MirrorOutcome{Err: &MirrorError{Class: class, Detail: "detail"}}, now)
158+ want := MirrorFailing
159+ if fatal || n >= 5 {
160+ want = MirrorPaused
161+ }
162+ if tr.Status.State != want || tr.Status.ConsecutiveFailures != n || tr.FailedEvent != (n == 1) {
163+ t.Fatalf("n=%d %+v", n, tr)
164+ }
165+ if want == MirrorFailing && !tr.Status.NextRetryAt.Equal(now.Add(MirrorBackoff(n))) {
166+ t.Fatal(tr)
167+ }
168+ if want == MirrorPaused && (!tr.Status.NextRetryAt.IsZero() || tr.Status.PausedAt.IsZero()) {
169+ t.Fatal(tr)
170+ }
171+ p = tr.Status
172+ }
173+ for _, noop := range []bool{false, true} {
174+ tr := ApplyMirrorOutcome(p, MirrorOutcome{NoOp: noop}, now)
175+ if !tr.Recovered || tr.FailedEvent || tr.Status.State != MirrorOK || tr.Status.ConsecutiveFailures != 0 || !tr.Status.LastSuccessAt.Equal(now) || tr.Status.Detail != "" {
176+ t.Fatal(tr)
177+ }
178+ if ApplyMirrorOutcome(tr.Status, MirrorOutcome{}, now).Recovered {
179+ t.Fatal("repeat recovery")
180+ }
181+ }
182+ for _, state := range []MirrorState{MirrorOK, MirrorDisabled} {
183+ tr := ApplyMirrorOutcome(MirrorStatus{State: state}, MirrorOutcome{Err: &MirrorError{Class: class}}, now)
184+ if !tr.FailedEvent {
185+ t.Fatal(tr)
186+ }
187+ }
188+ if r := MirrorReset(p, now); r.State != MirrorOK || r.Class != "" || !r.PausedAt.IsZero() || r.ConsecutiveFailures != 0 {
189+ t.Fatal(r)
190+ }
191+ if d := MirrorDisable(p, now); d.State != MirrorDisabled || d.Reason != "" {
192+ t.Fatal(d)
193+ }
194+ wantErr := ErrUnavailable
195+ if fatal {
196+ wantErr = ErrPreconditionFailed
197+ }
198+ if !errors.Is(&MirrorError{Class: class}, wantErr) {
199+ t.Fatal("unwrap")
200+ }
201+ })
202+ }
203+ for _, tt := range []struct {
204+ n int
205+ want time.Duration
206+ }{{1, 5 * time.Minute}, {2, 10 * time.Minute}, {3, 20 * time.Minute}, {7, 320 * time.Minute}, {8, 6 * time.Hour}, {1000000, 6 * time.Hour}} {
207+ if got := MirrorBackoff(tt.n); got != tt.want {
208+ t.Fatal(tt, got)
209+ }
210+ }
211+}
212+func TestMirrorClassify(t *testing.T) {
213+ for _, tt := range []struct {
214+ s string
215+ c MirrorFailureClass
216+ }{{"non-fast-forward", MirrorClassDiverged}, {"fetch first", MirrorClassDiverged}, {"Permission denied (publickey)", MirrorClassAuthDenied}, {"Repository not found", MirrorClassRemoteNotFound}, {"does not appear to be a git repository", MirrorClassRemoteNotFound}, {"REMOTE HOST IDENTIFICATION HAS CHANGED", MirrorClassHostKey}, {"Host key verification failed", MirrorClassHostKey}, {"timeout", MirrorClassTransient},
217+ {"Permission denied (publickey,password).", MirrorClassAuthDenied}, {"Permission denied (password,publickey,keyboard-interactive).", MirrorClassAuthDenied},
218+ {"ERROR: Permission to x/y.git denied to deploy key", MirrorClassAuthDenied}, {"remote: Permission to x/y.git denied to someone.", MirrorClassAuthDenied},
219+ } {
220+ t.Run(tt.s, func(t *testing.T) {
221+ if c := ClassifyMirrorOutput(tt.s, errors.New("exit"), false); c != tt.c {
222+ t.Fatal(c)
223+ }
224+ })
225+ }
226+ if ClassifyMirrorOutput("", nil, false) != MirrorClassNone || ClassifyMirrorOutput("non-fast-forward", nil, true) != MirrorClassTransient {
227+ t.Fatal("classification")
228+ }
229+}
230+func TestMirrorRecoveryFromAnyState(t *testing.T) {
231+ now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
232+ for _, state := range []MirrorState{MirrorDisabled, MirrorOK, MirrorFailing, MirrorPaused} {
233+ for _, noop := range []bool{false, true} {
234+ tr := ApplyMirrorOutcome(MirrorStatus{State: state, ConsecutiveFailures: 2, Class: MirrorClassTransient, Detail: "old"}, MirrorOutcome{NoOp: noop}, now)
235+ if tr.Recovered != (state == MirrorFailing || state == MirrorPaused) || tr.FailedEvent || tr.Status.ConsecutiveFailures != 0 || tr.Status.Class != "" || tr.Status.Reason != "" || !tr.Status.NextRetryAt.IsZero() {
236+ t.Fatal(state, tr)
237+ }
238+ }
239+ }
240+}
241+func TestMirrorRefs(t *testing.T) {
242+ id, _ := ParseObjectID(strings.Repeat("a", 40))
243+ refs := []Ref{{Name: "HEAD", ID: id}, {Name: "refs/heads/main", ID: id}, {Name: "refs/heads/other", ID: id}, {Name: "refs/tags/v1", ID: id}, {Name: "refs/pull/1", ID: id}, {Name: "refs/heads/unborn"}, {Name: "refs/tags/\xff", ID: id}, {Name: "refs/heads/\xff", ID: id}}
244+ for _, all := range []bool{false, true} {
245+ got := MirrorRefspecs(refs, "main", all)
246+ n := 2
247+ if all {
248+ n = 3
249+ }
250+ if len(got) != n {
251+ t.Fatal(got)
252+ }
253+ for i, r := range got {
254+ if strings.Contains(r.Name+":"+r.Name, "+") || i > 0 && got[i-1].Name >= r.Name {
255+ t.Fatal(got)
256+ }
257+ }
258+ snap := MirrorSnapshot{URL: "host:repo", Refs: got, AllBranches: all}
259+ rev := append([]MirrorRef(nil), got...)
260+ rev[0], rev[len(rev)-1] = rev[len(rev)-1], rev[0]
261+ if !snap.Equal(snap.URL, rev, all) || snap.Equal("other", rev, all) || snap.Equal(snap.URL, rev, !all) || snap.Equal(snap.URL, rev[1:], all) {
262+ t.Fatal("snapshot")
263+ }
264+ rev[0].ID = ObjectID{}
265+ if snap.Equal(snap.URL, rev, all) {
266+ t.Fatal("changed ID")
267+ }
268+ }
269+}
270+func TestMirrorKey(t *testing.T) {
271+ for _, tt := range []struct{ url, want string }{{"https://forge.example:443/path", "ermite-mirror@forge.example"}, {"", "ermite-mirror@localhost"}, {":bad", "ermite-mirror@localhost"}} {
272+ if got := MirrorKeyComment(tt.url); got != tt.want {
273+ t.Fatal(got)
274+ }
275+ }
276+ pub := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)).Public().(ed25519.PublicKey)
277+ now := time.Now()
278+ key := MirrorPublicKeyOf(pub, "forge.example", now)
279+ parts := strings.Fields(key.AuthorizedKey)
280+ if len(parts) != 3 || parts[0] != "ssh-ed25519" || parts[2] != "ermite-mirror@forge.example" || !key.CreatedAt.Equal(now) {
281+ t.Fatal(key)
282+ }
283+ wire, e := base64.StdEncoding.DecodeString(parts[1])
284+ if e != nil || binary.BigEndian.Uint32(wire[:4]) != 11 || string(wire[4:15]) != "ssh-ed25519" || binary.BigEndian.Uint32(wire[15:19]) != 32 || string(wire[19:]) != string(pub) {
285+ t.Fatal("wire encoding")
286+ }
287+ hash := sha256.Sum256(wire)
288+ if key.Fingerprint != "SHA256:"+base64.RawStdEncoding.EncodeToString(hash[:]) {
289+ t.Fatal(key)
290+ }
291+}
292+
293+func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294+ g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295+ repo, _ := ParseRepoName("demo")
296+ if _, err := ResolveMirror(g, nil, repo); err != nil {
297+ t.Fatal(err)
298+ }
299+ if ValidateMirrorGlobalConfig(g) == nil {
300+ t.Fatal("mutation must still reject short gap")
301+ }
302+}
A internal/ports/mirror.go +122 -0
@@ -0,0 +1,122 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "bnema.dev/ermite/internal/domain"
7+ "context"
8+ "crypto/ed25519"
9+ "time"
10+)
11+
12+// MirrorStore keeps the mirror configuration, state and snapshots (kvstore implements it).
13+type MirrorStore interface {
14+ GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) // zero value, nil error when unset
15+ PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error
16+ GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) // domain.ErrNotFound when none
17+ PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error // ErrNotFound when the repository is gone (guardRepoKey)
18+ DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error // missing is not an error
19+ ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) // by repo name; with ListStatus, what ListStatus of the service reads
20+ GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) // ErrNotFound when none
21+ PutStatus(ctx context.Context, s domain.MirrorStatus) error
22+ ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) // stored statuses only
23+ GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) // ErrNotFound when none
24+ PutSnapshot(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error
25+}
26+
27+// MirrorKeyStore keeps the sealed seed of the mirror key. The use case seals/opens it with
28+// SecretSealer (AAD domain.MirrorKeyPath), as OIDC does.
29+type MirrorKeyStore interface {
30+ // GetMirrorKey returns the sealed seed and its creation time, or ErrNotFound.
31+ GetMirrorKey(ctx context.Context) (sealed []byte, createdAt time.Time, err error)
32+ // PutMirrorKey replaces the key; ifAbsent makes it fail with ErrAlreadyExists when one exists.
33+ PutMirrorKey(ctx context.Context, sealed []byte, createdAt time.Time, ifAbsent bool) error
34+}
35+
36+// MirrorPush is one push request to the transport.
37+type MirrorPush struct {
38+ Repo domain.RepoName
39+ URL string
40+ Refspecs []string // "<ref>:<ref>", no "+", from domain.MirrorRefspecs
41+ PrivateKey ed25519.PrivateKey // written to a 0600 file for the duration of the push
42+}
43+type MirrorRefStatus string
44+
45+const (
46+ MirrorRefOK MirrorRefStatus = "ok" // porcelain ' ' or '*'
47+ MirrorRefUpToDate MirrorRefStatus = "up_to_date" // '='
48+ MirrorRefRejected MirrorRefStatus = "rejected" // '!'
49+)
50+
51+type MirrorRefOutcome struct {
52+ Ref string
53+ Status MirrorRefStatus
54+ Reason string
55+}
56+type MirrorPushResult struct {
57+ Refs []MirrorRefOutcome
58+ Duration time.Duration
59+}
60+
61+// MirrorPusher runs `git push --porcelain` (gitpush implements it). A failed push returns a
62+// *domain.MirrorError (classified); ctx cancellation is returned as is. A result with a rejected
63+// ref and err == nil never happens: a rejection is a MirrorError of class diverged.
64+type MirrorPusher interface {
65+ Push(ctx context.Context, req MirrorPush) (MirrorPushResult, error)
66+}
67+
68+// MirrorTrigger is what Pushes.Record tells the mirrors. *usecase.Mirrors implements it.
69+type MirrorTrigger interface {
70+ OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) // never blocks
71+}
72+
73+// MirrorWarner is what the post-receive hook and the web ask. *usecase.Mirrors implements it.
74+type MirrorWarner interface {
75+ // Warning is non-empty when the mirror of repo is failing or paused ("mirror to github.com:x/y is paused: <reason>")
76+ // and p has repo:admin on repo: the remote and the reason are for admins only.
77+ Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string
78+ // Badge is the state and reason for a viewer: reason is "" unless p may administer the repository.
79+ Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string)
80+}
81+type MirrorSyncResult struct {
82+ Repo domain.RepoName
83+ Status domain.MirrorStatus
84+ Queued bool
85+ NoOp bool
86+ Err error // SyncAll only: why this repository failed; the other repositories still ran
87+}
88+
89+// MirrorKeyRotation is the outcome of RotateKey; Previous is zero when no key existed.
90+type MirrorKeyRotation struct {
91+ Key domain.MirrorPublicKey
92+ Previous domain.MirrorPublicKey
93+}
94+
95+// MirrorService is the inbound port of the API. Authorization: key show needs a non-anonymous
96+// principal; key rotate, global config and ListStatus need HasOwnerRights(); per-repo config and
97+// Sync need Allows(ScopeRepoAdmin, repo); GetStatus needs read access to the repository.
98+type MirrorService interface {
99+ ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) // generates lazily
100+ RotateKey(ctx context.Context, p domain.Principal) (MirrorKeyRotation, error)
101+ GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error)
102+ SetGlobalConfig(ctx context.Context, p domain.Principal, patch MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)
103+ SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error)
104+ RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error
105+ SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error)
106+ GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (MirrorStatusView, error)
107+ ListStatus(ctx context.Context, p domain.Principal) ([]MirrorStatusView, error)
108+ Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (MirrorSyncResult, error)
109+ // SyncAll skips disabled mirrors and continues past failures, reported per repository in Err.
110+ SyncAll(ctx context.Context, p domain.Principal, force bool) ([]MirrorSyncResult, error)
111+}
112+type MirrorGlobalPatch struct {
113+ Enabled *bool
114+ URLTemplate *string
115+ Cron *string
116+ AllBranches *bool
117+}
118+type MirrorStatusView struct {
119+ Target domain.MirrorTarget
120+ Status domain.MirrorStatus
121+ NextRunAt time.Time
122+}
A internal/ports/mocks/MirrorKeyStore.go +185 -0
@@ -0,0 +1,185 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMirrorKeyStore creates a new instance of MirrorKeyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMirrorKeyStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MirrorKeyStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MirrorKeyStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MirrorKeyStore is an autogenerated mock type for the MirrorKeyStore type
38+type MirrorKeyStore struct {
39+ mock.Mock
40+}
41+
42+type MirrorKeyStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MirrorKeyStore) EXPECT() *MirrorKeyStore_Expecter {
47+ return &MirrorKeyStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// GetMirrorKey provides a mock function for the type MirrorKeyStore
51+func (_mock *MirrorKeyStore) GetMirrorKey(ctx context.Context) ([]byte, time.Time, error) {
52+ ret := _mock.Called(ctx)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for GetMirrorKey")
56+ }
57+
58+ var r0 []byte
59+ var r1 time.Time
60+ var r2 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]byte, time.Time, error)); ok {
62+ return returnFunc(ctx)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context) []byte); ok {
65+ r0 = returnFunc(ctx)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]byte)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context) time.Time); ok {
72+ r1 = returnFunc(ctx)
73+ } else {
74+ r1 = ret.Get(1).(time.Time)
75+ }
76+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
77+ r2 = returnFunc(ctx)
78+ } else {
79+ r2 = ret.Error(2)
80+ }
81+ return r0, r1, r2
82+}
83+
84+// MirrorKeyStore_GetMirrorKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMirrorKey'
85+type MirrorKeyStore_GetMirrorKey_Call struct {
86+ *mock.Call
87+}
88+
89+// GetMirrorKey is a helper method to define mock.On call
90+// - ctx context.Context
91+func (_e *MirrorKeyStore_Expecter) GetMirrorKey(ctx any) *MirrorKeyStore_GetMirrorKey_Call {
92+ return &MirrorKeyStore_GetMirrorKey_Call{Call: _e.mock.On("GetMirrorKey", ctx)}
93+}
94+
95+func (_c *MirrorKeyStore_GetMirrorKey_Call) Run(run func(ctx context.Context)) *MirrorKeyStore_GetMirrorKey_Call {
96+ _c.Call.Run(func(args mock.Arguments) {
97+ var arg0 context.Context
98+ if args[0] != nil {
99+ arg0 = args[0].(context.Context)
100+ }
101+ run(
102+ arg0,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *MirrorKeyStore_GetMirrorKey_Call) Return(sealed []byte, createdAt time.Time, err error) *MirrorKeyStore_GetMirrorKey_Call {
109+ _c.Call.Return(sealed, createdAt, err)
110+ return _c
111+}
112+
113+func (_c *MirrorKeyStore_GetMirrorKey_Call) RunAndReturn(run func(ctx context.Context) ([]byte, time.Time, error)) *MirrorKeyStore_GetMirrorKey_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// PutMirrorKey provides a mock function for the type MirrorKeyStore
119+func (_mock *MirrorKeyStore) PutMirrorKey(ctx context.Context, sealed []byte, createdAt time.Time, ifAbsent bool) error {
120+ ret := _mock.Called(ctx, sealed, createdAt, ifAbsent)
121+
122+ if len(ret) == 0 {
123+ panic("no return value specified for PutMirrorKey")
124+ }
125+
126+ var r0 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, []byte, time.Time, bool) error); ok {
128+ r0 = returnFunc(ctx, sealed, createdAt, ifAbsent)
129+ } else {
130+ r0 = ret.Error(0)
131+ }
132+ return r0
133+}
134+
135+// MirrorKeyStore_PutMirrorKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutMirrorKey'
136+type MirrorKeyStore_PutMirrorKey_Call struct {
137+ *mock.Call
138+}
139+
140+// PutMirrorKey is a helper method to define mock.On call
141+// - ctx context.Context
142+// - sealed []byte
143+// - createdAt time.Time
144+// - ifAbsent bool
145+func (_e *MirrorKeyStore_Expecter) PutMirrorKey(ctx any, sealed any, createdAt any, ifAbsent any) *MirrorKeyStore_PutMirrorKey_Call {
146+ return &MirrorKeyStore_PutMirrorKey_Call{Call: _e.mock.On("PutMirrorKey", ctx, sealed, createdAt, ifAbsent)}
147+}
148+
149+func (_c *MirrorKeyStore_PutMirrorKey_Call) Run(run func(ctx context.Context, sealed []byte, createdAt time.Time, ifAbsent bool)) *MirrorKeyStore_PutMirrorKey_Call {
150+ _c.Call.Run(func(args mock.Arguments) {
151+ var arg0 context.Context
152+ if args[0] != nil {
153+ arg0 = args[0].(context.Context)
154+ }
155+ var arg1 []byte
156+ if args[1] != nil {
157+ arg1 = args[1].([]byte)
158+ }
159+ var arg2 time.Time
160+ if args[2] != nil {
161+ arg2 = args[2].(time.Time)
162+ }
163+ var arg3 bool
164+ if args[3] != nil {
165+ arg3 = args[3].(bool)
166+ }
167+ run(
168+ arg0,
169+ arg1,
170+ arg2,
171+ arg3,
172+ )
173+ })
174+ return _c
175+}
176+
177+func (_c *MirrorKeyStore_PutMirrorKey_Call) Return(err error) *MirrorKeyStore_PutMirrorKey_Call {
178+ _c.Call.Return(err)
179+ return _c
180+}
181+
182+func (_c *MirrorKeyStore_PutMirrorKey_Call) RunAndReturn(run func(ctx context.Context, sealed []byte, createdAt time.Time, ifAbsent bool) error) *MirrorKeyStore_PutMirrorKey_Call {
183+ _c.Call.Return(run)
184+ return _c
185+}
A internal/ports/mocks/MirrorPusher.go +114 -0
@@ -0,0 +1,114 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMirrorPusher creates a new instance of MirrorPusher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMirrorPusher(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MirrorPusher {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MirrorPusher{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MirrorPusher is an autogenerated mock type for the MirrorPusher type
38+type MirrorPusher struct {
39+ mock.Mock
40+}
41+
42+type MirrorPusher_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MirrorPusher) EXPECT() *MirrorPusher_Expecter {
47+ return &MirrorPusher_Expecter{mock: &_m.Mock}
48+}
49+
50+// Push provides a mock function for the type MirrorPusher
51+func (_mock *MirrorPusher) Push(ctx context.Context, req ports.MirrorPush) (ports.MirrorPushResult, error) {
52+ ret := _mock.Called(ctx, req)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Push")
56+ }
57+
58+ var r0 ports.MirrorPushResult
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error)); ok {
61+ return returnFunc(ctx, req)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.MirrorPush) ports.MirrorPushResult); ok {
64+ r0 = returnFunc(ctx, req)
65+ } else {
66+ r0 = ret.Get(0).(ports.MirrorPushResult)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.MirrorPush) error); ok {
69+ r1 = returnFunc(ctx, req)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// MirrorPusher_Push_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Push'
77+type MirrorPusher_Push_Call struct {
78+ *mock.Call
79+}
80+
81+// Push is a helper method to define mock.On call
82+// - ctx context.Context
83+// - req ports.MirrorPush
84+func (_e *MirrorPusher_Expecter) Push(ctx any, req any) *MirrorPusher_Push_Call {
85+ return &MirrorPusher_Push_Call{Call: _e.mock.On("Push", ctx, req)}
86+}
87+
88+func (_c *MirrorPusher_Push_Call) Run(run func(ctx context.Context, req ports.MirrorPush)) *MirrorPusher_Push_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 ports.MirrorPush
95+ if args[1] != nil {
96+ arg1 = args[1].(ports.MirrorPush)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *MirrorPusher_Push_Call) Return(mirrorPushResult ports.MirrorPushResult, err error) *MirrorPusher_Push_Call {
107+ _c.Call.Return(mirrorPushResult, err)
108+ return _c
109+}
110+
111+func (_c *MirrorPusher_Push_Call) RunAndReturn(run func(ctx context.Context, req ports.MirrorPush) (ports.MirrorPushResult, error)) *MirrorPusher_Push_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
A internal/ports/mocks/MirrorService.go +824 -0
@@ -0,0 +1,824 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMirrorService creates a new instance of MirrorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMirrorService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MirrorService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &MirrorService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// MirrorService is an autogenerated mock type for the MirrorService type
39+type MirrorService struct {
40+ mock.Mock
41+}
42+
43+type MirrorService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *MirrorService) EXPECT() *MirrorService_Expecter {
48+ return &MirrorService_Expecter{mock: &_m.Mock}
49+}
50+
51+// GetGlobalConfig provides a mock function for the type MirrorService
52+func (_mock *MirrorService) GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error) {
53+ ret := _mock.Called(ctx, p)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for GetGlobalConfig")
57+ }
58+
59+ var r0 domain.MirrorGlobalConfig
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (domain.MirrorGlobalConfig, error)); ok {
62+ return returnFunc(ctx, p)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) domain.MirrorGlobalConfig); ok {
65+ r0 = returnFunc(ctx, p)
66+ } else {
67+ r0 = ret.Get(0).(domain.MirrorGlobalConfig)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
70+ r1 = returnFunc(ctx, p)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// MirrorService_GetGlobalConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetGlobalConfig'
78+type MirrorService_GetGlobalConfig_Call struct {
79+ *mock.Call
80+}
81+
82+// GetGlobalConfig is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+func (_e *MirrorService_Expecter) GetGlobalConfig(ctx any, p any) *MirrorService_GetGlobalConfig_Call {
86+ return &MirrorService_GetGlobalConfig_Call{Call: _e.mock.On("GetGlobalConfig", ctx, p)}
87+}
88+
89+func (_c *MirrorService_GetGlobalConfig_Call) Run(run func(ctx context.Context, p domain.Principal)) *MirrorService_GetGlobalConfig_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ run(
100+ arg0,
101+ arg1,
102+ )
103+ })
104+ return _c
105+}
106+
107+func (_c *MirrorService_GetGlobalConfig_Call) Return(mirrorGlobalConfig domain.MirrorGlobalConfig, err error) *MirrorService_GetGlobalConfig_Call {
108+ _c.Call.Return(mirrorGlobalConfig, err)
109+ return _c
110+}
111+
112+func (_c *MirrorService_GetGlobalConfig_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error)) *MirrorService_GetGlobalConfig_Call {
113+ _c.Call.Return(run)
114+ return _c
115+}
116+
117+// GetStatus provides a mock function for the type MirrorService
118+func (_mock *MirrorService) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {
119+ ret := _mock.Called(ctx, p, repo)
120+
121+ if len(ret) == 0 {
122+ panic("no return value specified for GetStatus")
123+ }
124+
125+ var r0 ports.MirrorStatusView
126+ var r1 error
127+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (ports.MirrorStatusView, error)); ok {
128+ return returnFunc(ctx, p, repo)
129+ }
130+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) ports.MirrorStatusView); ok {
131+ r0 = returnFunc(ctx, p, repo)
132+ } else {
133+ r0 = ret.Get(0).(ports.MirrorStatusView)
134+ }
135+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
136+ r1 = returnFunc(ctx, p, repo)
137+ } else {
138+ r1 = ret.Error(1)
139+ }
140+ return r0, r1
141+}
142+
143+// MirrorService_GetStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStatus'
144+type MirrorService_GetStatus_Call struct {
145+ *mock.Call
146+}
147+
148+// GetStatus is a helper method to define mock.On call
149+// - ctx context.Context
150+// - p domain.Principal
151+// - repo domain.RepoName
152+func (_e *MirrorService_Expecter) GetStatus(ctx any, p any, repo any) *MirrorService_GetStatus_Call {
153+ return &MirrorService_GetStatus_Call{Call: _e.mock.On("GetStatus", ctx, p, repo)}
154+}
155+
156+func (_c *MirrorService_GetStatus_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorService_GetStatus_Call {
157+ _c.Call.Run(func(args mock.Arguments) {
158+ var arg0 context.Context
159+ if args[0] != nil {
160+ arg0 = args[0].(context.Context)
161+ }
162+ var arg1 domain.Principal
163+ if args[1] != nil {
164+ arg1 = args[1].(domain.Principal)
165+ }
166+ var arg2 domain.RepoName
167+ if args[2] != nil {
168+ arg2 = args[2].(domain.RepoName)
169+ }
170+ run(
171+ arg0,
172+ arg1,
173+ arg2,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *MirrorService_GetStatus_Call) Return(mirrorStatusView ports.MirrorStatusView, err error) *MirrorService_GetStatus_Call {
180+ _c.Call.Return(mirrorStatusView, err)
181+ return _c
182+}
183+
184+func (_c *MirrorService_GetStatus_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error)) *MirrorService_GetStatus_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// ListStatus provides a mock function for the type MirrorService
190+func (_mock *MirrorService) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {
191+ ret := _mock.Called(ctx, p)
192+
193+ if len(ret) == 0 {
194+ panic("no return value specified for ListStatus")
195+ }
196+
197+ var r0 []ports.MirrorStatusView
198+ var r1 error
199+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ([]ports.MirrorStatusView, error)); ok {
200+ return returnFunc(ctx, p)
201+ }
202+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) []ports.MirrorStatusView); ok {
203+ r0 = returnFunc(ctx, p)
204+ } else {
205+ if ret.Get(0) != nil {
206+ r0 = ret.Get(0).([]ports.MirrorStatusView)
207+ }
208+ }
209+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
210+ r1 = returnFunc(ctx, p)
211+ } else {
212+ r1 = ret.Error(1)
213+ }
214+ return r0, r1
215+}
216+
217+// MirrorService_ListStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListStatus'
218+type MirrorService_ListStatus_Call struct {
219+ *mock.Call
220+}
221+
222+// ListStatus is a helper method to define mock.On call
223+// - ctx context.Context
224+// - p domain.Principal
225+func (_e *MirrorService_Expecter) ListStatus(ctx any, p any) *MirrorService_ListStatus_Call {
226+ return &MirrorService_ListStatus_Call{Call: _e.mock.On("ListStatus", ctx, p)}
227+}
228+
229+func (_c *MirrorService_ListStatus_Call) Run(run func(ctx context.Context, p domain.Principal)) *MirrorService_ListStatus_Call {
230+ _c.Call.Run(func(args mock.Arguments) {
231+ var arg0 context.Context
232+ if args[0] != nil {
233+ arg0 = args[0].(context.Context)
234+ }
235+ var arg1 domain.Principal
236+ if args[1] != nil {
237+ arg1 = args[1].(domain.Principal)
238+ }
239+ run(
240+ arg0,
241+ arg1,
242+ )
243+ })
244+ return _c
245+}
246+
247+func (_c *MirrorService_ListStatus_Call) Return(mirrorStatusViews []ports.MirrorStatusView, err error) *MirrorService_ListStatus_Call {
248+ _c.Call.Return(mirrorStatusViews, err)
249+ return _c
250+}
251+
252+func (_c *MirrorService_ListStatus_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error)) *MirrorService_ListStatus_Call {
253+ _c.Call.Return(run)
254+ return _c
255+}
256+
257+// RemoveRepoMirror provides a mock function for the type MirrorService
258+func (_mock *MirrorService) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error {
259+ ret := _mock.Called(ctx, p, repo)
260+
261+ if len(ret) == 0 {
262+ panic("no return value specified for RemoveRepoMirror")
263+ }
264+
265+ var r0 error
266+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
267+ r0 = returnFunc(ctx, p, repo)
268+ } else {
269+ r0 = ret.Error(0)
270+ }
271+ return r0
272+}
273+
274+// MirrorService_RemoveRepoMirror_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRepoMirror'
275+type MirrorService_RemoveRepoMirror_Call struct {
276+ *mock.Call
277+}
278+
279+// RemoveRepoMirror is a helper method to define mock.On call
280+// - ctx context.Context
281+// - p domain.Principal
282+// - repo domain.RepoName
283+func (_e *MirrorService_Expecter) RemoveRepoMirror(ctx any, p any, repo any) *MirrorService_RemoveRepoMirror_Call {
284+ return &MirrorService_RemoveRepoMirror_Call{Call: _e.mock.On("RemoveRepoMirror", ctx, p, repo)}
285+}
286+
287+func (_c *MirrorService_RemoveRepoMirror_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorService_RemoveRepoMirror_Call {
288+ _c.Call.Run(func(args mock.Arguments) {
289+ var arg0 context.Context
290+ if args[0] != nil {
291+ arg0 = args[0].(context.Context)
292+ }
293+ var arg1 domain.Principal
294+ if args[1] != nil {
295+ arg1 = args[1].(domain.Principal)
296+ }
297+ var arg2 domain.RepoName
298+ if args[2] != nil {
299+ arg2 = args[2].(domain.RepoName)
300+ }
301+ run(
302+ arg0,
303+ arg1,
304+ arg2,
305+ )
306+ })
307+ return _c
308+}
309+
310+func (_c *MirrorService_RemoveRepoMirror_Call) Return(err error) *MirrorService_RemoveRepoMirror_Call {
311+ _c.Call.Return(err)
312+ return _c
313+}
314+
315+func (_c *MirrorService_RemoveRepoMirror_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) error) *MirrorService_RemoveRepoMirror_Call {
316+ _c.Call.Return(run)
317+ return _c
318+}
319+
320+// RotateKey provides a mock function for the type MirrorService
321+func (_mock *MirrorService) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
322+ ret := _mock.Called(ctx, p)
323+
324+ if len(ret) == 0 {
325+ panic("no return value specified for RotateKey")
326+ }
327+
328+ var r0 ports.MirrorKeyRotation
329+ var r1 error
330+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.MirrorKeyRotation, error)); ok {
331+ return returnFunc(ctx, p)
332+ }
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.MirrorKeyRotation); ok {
334+ r0 = returnFunc(ctx, p)
335+ } else {
336+ r0 = ret.Get(0).(ports.MirrorKeyRotation)
337+ }
338+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
339+ r1 = returnFunc(ctx, p)
340+ } else {
341+ r1 = ret.Error(1)
342+ }
343+ return r0, r1
344+}
345+
346+// MirrorService_RotateKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RotateKey'
347+type MirrorService_RotateKey_Call struct {
348+ *mock.Call
349+}
350+
351+// RotateKey is a helper method to define mock.On call
352+// - ctx context.Context
353+// - p domain.Principal
354+func (_e *MirrorService_Expecter) RotateKey(ctx any, p any) *MirrorService_RotateKey_Call {
355+ return &MirrorService_RotateKey_Call{Call: _e.mock.On("RotateKey", ctx, p)}
356+}
357+
358+func (_c *MirrorService_RotateKey_Call) Run(run func(ctx context.Context, p domain.Principal)) *MirrorService_RotateKey_Call {
359+ _c.Call.Run(func(args mock.Arguments) {
360+ var arg0 context.Context
361+ if args[0] != nil {
362+ arg0 = args[0].(context.Context)
363+ }
364+ var arg1 domain.Principal
365+ if args[1] != nil {
366+ arg1 = args[1].(domain.Principal)
367+ }
368+ run(
369+ arg0,
370+ arg1,
371+ )
372+ })
373+ return _c
374+}
375+
376+func (_c *MirrorService_RotateKey_Call) Return(mirrorKeyRotation ports.MirrorKeyRotation, err error) *MirrorService_RotateKey_Call {
377+ _c.Call.Return(mirrorKeyRotation, err)
378+ return _c
379+}
380+
381+func (_c *MirrorService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error)) *MirrorService_RotateKey_Call {
382+ _c.Call.Return(run)
383+ return _c
384+}
385+
386+// SetGlobalConfig provides a mock function for the type MirrorService
387+func (_mock *MirrorService) SetGlobalConfig(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error) {
388+ ret := _mock.Called(ctx, p, patch)
389+
390+ if len(ret) == 0 {
391+ panic("no return value specified for SetGlobalConfig")
392+ }
393+
394+ var r0 domain.MirrorGlobalConfig
395+ var r1 error
396+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)); ok {
397+ return returnFunc(ctx, p, patch)
398+ }
399+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.MirrorGlobalPatch) domain.MirrorGlobalConfig); ok {
400+ r0 = returnFunc(ctx, p, patch)
401+ } else {
402+ r0 = ret.Get(0).(domain.MirrorGlobalConfig)
403+ }
404+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.MirrorGlobalPatch) error); ok {
405+ r1 = returnFunc(ctx, p, patch)
406+ } else {
407+ r1 = ret.Error(1)
408+ }
409+ return r0, r1
410+}
411+
412+// MirrorService_SetGlobalConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetGlobalConfig'
413+type MirrorService_SetGlobalConfig_Call struct {
414+ *mock.Call
415+}
416+
417+// SetGlobalConfig is a helper method to define mock.On call
418+// - ctx context.Context
419+// - p domain.Principal
420+// - patch ports.MirrorGlobalPatch
421+func (_e *MirrorService_Expecter) SetGlobalConfig(ctx any, p any, patch any) *MirrorService_SetGlobalConfig_Call {
422+ return &MirrorService_SetGlobalConfig_Call{Call: _e.mock.On("SetGlobalConfig", ctx, p, patch)}
423+}
424+
425+func (_c *MirrorService_SetGlobalConfig_Call) Run(run func(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch)) *MirrorService_SetGlobalConfig_Call {
426+ _c.Call.Run(func(args mock.Arguments) {
427+ var arg0 context.Context
428+ if args[0] != nil {
429+ arg0 = args[0].(context.Context)
430+ }
431+ var arg1 domain.Principal
432+ if args[1] != nil {
433+ arg1 = args[1].(domain.Principal)
434+ }
435+ var arg2 ports.MirrorGlobalPatch
436+ if args[2] != nil {
437+ arg2 = args[2].(ports.MirrorGlobalPatch)
438+ }
439+ run(
440+ arg0,
441+ arg1,
442+ arg2,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *MirrorService_SetGlobalConfig_Call) Return(mirrorGlobalConfig domain.MirrorGlobalConfig, err error) *MirrorService_SetGlobalConfig_Call {
449+ _c.Call.Return(mirrorGlobalConfig, err)
450+ return _c
451+}
452+
453+func (_c *MirrorService_SetGlobalConfig_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)) *MirrorService_SetGlobalConfig_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
457+
458+// SetRepoExcluded provides a mock function for the type MirrorService
459+func (_mock *MirrorService) SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error) {
460+ ret := _mock.Called(ctx, p, repo, excluded)
461+
462+ if len(ret) == 0 {
463+ panic("no return value specified for SetRepoExcluded")
464+ }
465+
466+ var r0 domain.MirrorTarget
467+ var r1 error
468+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) (domain.MirrorTarget, error)); ok {
469+ return returnFunc(ctx, p, repo, excluded)
470+ }
471+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) domain.MirrorTarget); ok {
472+ r0 = returnFunc(ctx, p, repo, excluded)
473+ } else {
474+ r0 = ret.Get(0).(domain.MirrorTarget)
475+ }
476+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok {
477+ r1 = returnFunc(ctx, p, repo, excluded)
478+ } else {
479+ r1 = ret.Error(1)
480+ }
481+ return r0, r1
482+}
483+
484+// MirrorService_SetRepoExcluded_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRepoExcluded'
485+type MirrorService_SetRepoExcluded_Call struct {
486+ *mock.Call
487+}
488+
489+// SetRepoExcluded is a helper method to define mock.On call
490+// - ctx context.Context
491+// - p domain.Principal
492+// - repo domain.RepoName
493+// - excluded bool
494+func (_e *MirrorService_Expecter) SetRepoExcluded(ctx any, p any, repo any, excluded any) *MirrorService_SetRepoExcluded_Call {
495+ return &MirrorService_SetRepoExcluded_Call{Call: _e.mock.On("SetRepoExcluded", ctx, p, repo, excluded)}
496+}
497+
498+func (_c *MirrorService_SetRepoExcluded_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool)) *MirrorService_SetRepoExcluded_Call {
499+ _c.Call.Run(func(args mock.Arguments) {
500+ var arg0 context.Context
501+ if args[0] != nil {
502+ arg0 = args[0].(context.Context)
503+ }
504+ var arg1 domain.Principal
505+ if args[1] != nil {
506+ arg1 = args[1].(domain.Principal)
507+ }
508+ var arg2 domain.RepoName
509+ if args[2] != nil {
510+ arg2 = args[2].(domain.RepoName)
511+ }
512+ var arg3 bool
513+ if args[3] != nil {
514+ arg3 = args[3].(bool)
515+ }
516+ run(
517+ arg0,
518+ arg1,
519+ arg2,
520+ arg3,
521+ )
522+ })
523+ return _c
524+}
525+
526+func (_c *MirrorService_SetRepoExcluded_Call) Return(mirrorTarget domain.MirrorTarget, err error) *MirrorService_SetRepoExcluded_Call {
527+ _c.Call.Return(mirrorTarget, err)
528+ return _c
529+}
530+
531+func (_c *MirrorService_SetRepoExcluded_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error)) *MirrorService_SetRepoExcluded_Call {
532+ _c.Call.Return(run)
533+ return _c
534+}
535+
536+// SetRepoMirror provides a mock function for the type MirrorService
537+func (_mock *MirrorService) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
538+ ret := _mock.Called(ctx, p, c)
539+
540+ if len(ret) == 0 {
541+ panic("no return value specified for SetRepoMirror")
542+ }
543+
544+ var r0 domain.MirrorTarget
545+ var r1 error
546+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.MirrorRepoConfig) (domain.MirrorTarget, error)); ok {
547+ return returnFunc(ctx, p, c)
548+ }
549+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.MirrorRepoConfig) domain.MirrorTarget); ok {
550+ r0 = returnFunc(ctx, p, c)
551+ } else {
552+ r0 = ret.Get(0).(domain.MirrorTarget)
553+ }
554+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.MirrorRepoConfig) error); ok {
555+ r1 = returnFunc(ctx, p, c)
556+ } else {
557+ r1 = ret.Error(1)
558+ }
559+ return r0, r1
560+}
561+
562+// MirrorService_SetRepoMirror_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SetRepoMirror'
563+type MirrorService_SetRepoMirror_Call struct {
564+ *mock.Call
565+}
566+
567+// SetRepoMirror is a helper method to define mock.On call
568+// - ctx context.Context
569+// - p domain.Principal
570+// - c domain.MirrorRepoConfig
571+func (_e *MirrorService_Expecter) SetRepoMirror(ctx any, p any, c any) *MirrorService_SetRepoMirror_Call {
572+ return &MirrorService_SetRepoMirror_Call{Call: _e.mock.On("SetRepoMirror", ctx, p, c)}
573+}
574+
575+func (_c *MirrorService_SetRepoMirror_Call) Run(run func(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig)) *MirrorService_SetRepoMirror_Call {
576+ _c.Call.Run(func(args mock.Arguments) {
577+ var arg0 context.Context
578+ if args[0] != nil {
579+ arg0 = args[0].(context.Context)
580+ }
581+ var arg1 domain.Principal
582+ if args[1] != nil {
583+ arg1 = args[1].(domain.Principal)
584+ }
585+ var arg2 domain.MirrorRepoConfig
586+ if args[2] != nil {
587+ arg2 = args[2].(domain.MirrorRepoConfig)
588+ }
589+ run(
590+ arg0,
591+ arg1,
592+ arg2,
593+ )
594+ })
595+ return _c
596+}
597+
598+func (_c *MirrorService_SetRepoMirror_Call) Return(mirrorTarget domain.MirrorTarget, err error) *MirrorService_SetRepoMirror_Call {
599+ _c.Call.Return(mirrorTarget, err)
600+ return _c
601+}
602+
603+func (_c *MirrorService_SetRepoMirror_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error)) *MirrorService_SetRepoMirror_Call {
604+ _c.Call.Return(run)
605+ return _c
606+}
607+
608+// ShowKey provides a mock function for the type MirrorService
609+func (_mock *MirrorService) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
610+ ret := _mock.Called(ctx, p)
611+
612+ if len(ret) == 0 {
613+ panic("no return value specified for ShowKey")
614+ }
615+
616+ var r0 domain.MirrorPublicKey
617+ var r1 error
618+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (domain.MirrorPublicKey, error)); ok {
619+ return returnFunc(ctx, p)
620+ }
621+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) domain.MirrorPublicKey); ok {
622+ r0 = returnFunc(ctx, p)
623+ } else {
624+ r0 = ret.Get(0).(domain.MirrorPublicKey)
625+ }
626+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
627+ r1 = returnFunc(ctx, p)
628+ } else {
629+ r1 = ret.Error(1)
630+ }
631+ return r0, r1
632+}
633+
634+// MirrorService_ShowKey_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShowKey'
635+type MirrorService_ShowKey_Call struct {
636+ *mock.Call
637+}
638+
639+// ShowKey is a helper method to define mock.On call
640+// - ctx context.Context
641+// - p domain.Principal
642+func (_e *MirrorService_Expecter) ShowKey(ctx any, p any) *MirrorService_ShowKey_Call {
643+ return &MirrorService_ShowKey_Call{Call: _e.mock.On("ShowKey", ctx, p)}
644+}
645+
646+func (_c *MirrorService_ShowKey_Call) Run(run func(ctx context.Context, p domain.Principal)) *MirrorService_ShowKey_Call {
647+ _c.Call.Run(func(args mock.Arguments) {
648+ var arg0 context.Context
649+ if args[0] != nil {
650+ arg0 = args[0].(context.Context)
651+ }
652+ var arg1 domain.Principal
653+ if args[1] != nil {
654+ arg1 = args[1].(domain.Principal)
655+ }
656+ run(
657+ arg0,
658+ arg1,
659+ )
660+ })
661+ return _c
662+}
663+
664+func (_c *MirrorService_ShowKey_Call) Return(mirrorPublicKey domain.MirrorPublicKey, err error) *MirrorService_ShowKey_Call {
665+ _c.Call.Return(mirrorPublicKey, err)
666+ return _c
667+}
668+
669+func (_c *MirrorService_ShowKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error)) *MirrorService_ShowKey_Call {
670+ _c.Call.Return(run)
671+ return _c
672+}
673+
674+// Sync provides a mock function for the type MirrorService
675+func (_mock *MirrorService) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
676+ ret := _mock.Called(ctx, p, repo, force)
677+
678+ if len(ret) == 0 {
679+ panic("no return value specified for Sync")
680+ }
681+
682+ var r0 ports.MirrorSyncResult
683+ var r1 error
684+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) (ports.MirrorSyncResult, error)); ok {
685+ return returnFunc(ctx, p, repo, force)
686+ }
687+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, bool) ports.MirrorSyncResult); ok {
688+ r0 = returnFunc(ctx, p, repo, force)
689+ } else {
690+ r0 = ret.Get(0).(ports.MirrorSyncResult)
691+ }
692+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, bool) error); ok {
693+ r1 = returnFunc(ctx, p, repo, force)
694+ } else {
695+ r1 = ret.Error(1)
696+ }
697+ return r0, r1
698+}
699+
700+// MirrorService_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
701+type MirrorService_Sync_Call struct {
702+ *mock.Call
703+}
704+
705+// Sync is a helper method to define mock.On call
706+// - ctx context.Context
707+// - p domain.Principal
708+// - repo domain.RepoName
709+// - force bool
710+func (_e *MirrorService_Expecter) Sync(ctx any, p any, repo any, force any) *MirrorService_Sync_Call {
711+ return &MirrorService_Sync_Call{Call: _e.mock.On("Sync", ctx, p, repo, force)}
712+}
713+
714+func (_c *MirrorService_Sync_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool)) *MirrorService_Sync_Call {
715+ _c.Call.Run(func(args mock.Arguments) {
716+ var arg0 context.Context
717+ if args[0] != nil {
718+ arg0 = args[0].(context.Context)
719+ }
720+ var arg1 domain.Principal
721+ if args[1] != nil {
722+ arg1 = args[1].(domain.Principal)
723+ }
724+ var arg2 domain.RepoName
725+ if args[2] != nil {
726+ arg2 = args[2].(domain.RepoName)
727+ }
728+ var arg3 bool
729+ if args[3] != nil {
730+ arg3 = args[3].(bool)
731+ }
732+ run(
733+ arg0,
734+ arg1,
735+ arg2,
736+ arg3,
737+ )
738+ })
739+ return _c
740+}
741+
742+func (_c *MirrorService_Sync_Call) Return(mirrorSyncResult ports.MirrorSyncResult, err error) *MirrorService_Sync_Call {
743+ _c.Call.Return(mirrorSyncResult, err)
744+ return _c
745+}
746+
747+func (_c *MirrorService_Sync_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error)) *MirrorService_Sync_Call {
748+ _c.Call.Return(run)
749+ return _c
750+}
751+
752+// SyncAll provides a mock function for the type MirrorService
753+func (_mock *MirrorService) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
754+ ret := _mock.Called(ctx, p, force)
755+
756+ if len(ret) == 0 {
757+ panic("no return value specified for SyncAll")
758+ }
759+
760+ var r0 []ports.MirrorSyncResult
761+ var r1 error
762+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, bool) ([]ports.MirrorSyncResult, error)); ok {
763+ return returnFunc(ctx, p, force)
764+ }
765+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, bool) []ports.MirrorSyncResult); ok {
766+ r0 = returnFunc(ctx, p, force)
767+ } else {
768+ if ret.Get(0) != nil {
769+ r0 = ret.Get(0).([]ports.MirrorSyncResult)
770+ }
771+ }
772+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, bool) error); ok {
773+ r1 = returnFunc(ctx, p, force)
774+ } else {
775+ r1 = ret.Error(1)
776+ }
777+ return r0, r1
778+}
779+
780+// MirrorService_SyncAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SyncAll'
781+type MirrorService_SyncAll_Call struct {
782+ *mock.Call
783+}
784+
785+// SyncAll is a helper method to define mock.On call
786+// - ctx context.Context
787+// - p domain.Principal
788+// - force bool
789+func (_e *MirrorService_Expecter) SyncAll(ctx any, p any, force any) *MirrorService_SyncAll_Call {
790+ return &MirrorService_SyncAll_Call{Call: _e.mock.On("SyncAll", ctx, p, force)}
791+}
792+
793+func (_c *MirrorService_SyncAll_Call) Run(run func(ctx context.Context, p domain.Principal, force bool)) *MirrorService_SyncAll_Call {
794+ _c.Call.Run(func(args mock.Arguments) {
795+ var arg0 context.Context
796+ if args[0] != nil {
797+ arg0 = args[0].(context.Context)
798+ }
799+ var arg1 domain.Principal
800+ if args[1] != nil {
801+ arg1 = args[1].(domain.Principal)
802+ }
803+ var arg2 bool
804+ if args[2] != nil {
805+ arg2 = args[2].(bool)
806+ }
807+ run(
808+ arg0,
809+ arg1,
810+ arg2,
811+ )
812+ })
813+ return _c
814+}
815+
816+func (_c *MirrorService_SyncAll_Call) Return(mirrorSyncResults []ports.MirrorSyncResult, err error) *MirrorService_SyncAll_Call {
817+ _c.Call.Return(mirrorSyncResults, err)
818+ return _c
819+}
820+
821+func (_c *MirrorService_SyncAll_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error)) *MirrorService_SyncAll_Call {
822+ _c.Call.Return(run)
823+ return _c
824+}
A internal/ports/mocks/MirrorStore.go +721 -0
@@ -0,0 +1,721 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMirrorStore creates a new instance of MirrorStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMirrorStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MirrorStore {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MirrorStore{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MirrorStore is an autogenerated mock type for the MirrorStore type
38+type MirrorStore struct {
39+ mock.Mock
40+}
41+
42+type MirrorStore_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MirrorStore) EXPECT() *MirrorStore_Expecter {
47+ return &MirrorStore_Expecter{mock: &_m.Mock}
48+}
49+
50+// DeleteRepoConfig provides a mock function for the type MirrorStore
51+func (_mock *MirrorStore) DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error {
52+ ret := _mock.Called(ctx, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for DeleteRepoConfig")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) error); ok {
60+ r0 = returnFunc(ctx, repo)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// MirrorStore_DeleteRepoConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteRepoConfig'
68+type MirrorStore_DeleteRepoConfig_Call struct {
69+ *mock.Call
70+}
71+
72+// DeleteRepoConfig is a helper method to define mock.On call
73+// - ctx context.Context
74+// - repo domain.RepoName
75+func (_e *MirrorStore_Expecter) DeleteRepoConfig(ctx any, repo any) *MirrorStore_DeleteRepoConfig_Call {
76+ return &MirrorStore_DeleteRepoConfig_Call{Call: _e.mock.On("DeleteRepoConfig", ctx, repo)}
77+}
78+
79+func (_c *MirrorStore_DeleteRepoConfig_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorStore_DeleteRepoConfig_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 domain.RepoName
86+ if args[1] != nil {
87+ arg1 = args[1].(domain.RepoName)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *MirrorStore_DeleteRepoConfig_Call) Return(err error) *MirrorStore_DeleteRepoConfig_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *MirrorStore_DeleteRepoConfig_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) error) *MirrorStore_DeleteRepoConfig_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
106+
107+// GetGlobal provides a mock function for the type MirrorStore
108+func (_mock *MirrorStore) GetGlobal(ctx context.Context) (domain.MirrorGlobalConfig, error) {
109+ ret := _mock.Called(ctx)
110+
111+ if len(ret) == 0 {
112+ panic("no return value specified for GetGlobal")
113+ }
114+
115+ var r0 domain.MirrorGlobalConfig
116+ var r1 error
117+ if returnFunc, ok := ret.Get(0).(func(context.Context) (domain.MirrorGlobalConfig, error)); ok {
118+ return returnFunc(ctx)
119+ }
120+ if returnFunc, ok := ret.Get(0).(func(context.Context) domain.MirrorGlobalConfig); ok {
121+ r0 = returnFunc(ctx)
122+ } else {
123+ r0 = ret.Get(0).(domain.MirrorGlobalConfig)
124+ }
125+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
126+ r1 = returnFunc(ctx)
127+ } else {
128+ r1 = ret.Error(1)
129+ }
130+ return r0, r1
131+}
132+
133+// MirrorStore_GetGlobal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetGlobal'
134+type MirrorStore_GetGlobal_Call struct {
135+ *mock.Call
136+}
137+
138+// GetGlobal is a helper method to define mock.On call
139+// - ctx context.Context
140+func (_e *MirrorStore_Expecter) GetGlobal(ctx any) *MirrorStore_GetGlobal_Call {
141+ return &MirrorStore_GetGlobal_Call{Call: _e.mock.On("GetGlobal", ctx)}
142+}
143+
144+func (_c *MirrorStore_GetGlobal_Call) Run(run func(ctx context.Context)) *MirrorStore_GetGlobal_Call {
145+ _c.Call.Run(func(args mock.Arguments) {
146+ var arg0 context.Context
147+ if args[0] != nil {
148+ arg0 = args[0].(context.Context)
149+ }
150+ run(
151+ arg0,
152+ )
153+ })
154+ return _c
155+}
156+
157+func (_c *MirrorStore_GetGlobal_Call) Return(mirrorGlobalConfig domain.MirrorGlobalConfig, err error) *MirrorStore_GetGlobal_Call {
158+ _c.Call.Return(mirrorGlobalConfig, err)
159+ return _c
160+}
161+
162+func (_c *MirrorStore_GetGlobal_Call) RunAndReturn(run func(ctx context.Context) (domain.MirrorGlobalConfig, error)) *MirrorStore_GetGlobal_Call {
163+ _c.Call.Return(run)
164+ return _c
165+}
166+
167+// GetRepoConfig provides a mock function for the type MirrorStore
168+func (_mock *MirrorStore) GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) {
169+ ret := _mock.Called(ctx, repo)
170+
171+ if len(ret) == 0 {
172+ panic("no return value specified for GetRepoConfig")
173+ }
174+
175+ var r0 domain.MirrorRepoConfig
176+ var r1 error
177+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.MirrorRepoConfig, error)); ok {
178+ return returnFunc(ctx, repo)
179+ }
180+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.MirrorRepoConfig); ok {
181+ r0 = returnFunc(ctx, repo)
182+ } else {
183+ r0 = ret.Get(0).(domain.MirrorRepoConfig)
184+ }
185+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
186+ r1 = returnFunc(ctx, repo)
187+ } else {
188+ r1 = ret.Error(1)
189+ }
190+ return r0, r1
191+}
192+
193+// MirrorStore_GetRepoConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRepoConfig'
194+type MirrorStore_GetRepoConfig_Call struct {
195+ *mock.Call
196+}
197+
198+// GetRepoConfig is a helper method to define mock.On call
199+// - ctx context.Context
200+// - repo domain.RepoName
201+func (_e *MirrorStore_Expecter) GetRepoConfig(ctx any, repo any) *MirrorStore_GetRepoConfig_Call {
202+ return &MirrorStore_GetRepoConfig_Call{Call: _e.mock.On("GetRepoConfig", ctx, repo)}
203+}
204+
205+func (_c *MirrorStore_GetRepoConfig_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorStore_GetRepoConfig_Call {
206+ _c.Call.Run(func(args mock.Arguments) {
207+ var arg0 context.Context
208+ if args[0] != nil {
209+ arg0 = args[0].(context.Context)
210+ }
211+ var arg1 domain.RepoName
212+ if args[1] != nil {
213+ arg1 = args[1].(domain.RepoName)
214+ }
215+ run(
216+ arg0,
217+ arg1,
218+ )
219+ })
220+ return _c
221+}
222+
223+func (_c *MirrorStore_GetRepoConfig_Call) Return(mirrorRepoConfig domain.MirrorRepoConfig, err error) *MirrorStore_GetRepoConfig_Call {
224+ _c.Call.Return(mirrorRepoConfig, err)
225+ return _c
226+}
227+
228+func (_c *MirrorStore_GetRepoConfig_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error)) *MirrorStore_GetRepoConfig_Call {
229+ _c.Call.Return(run)
230+ return _c
231+}
232+
233+// GetSnapshot provides a mock function for the type MirrorStore
234+func (_mock *MirrorStore) GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) {
235+ ret := _mock.Called(ctx, repo)
236+
237+ if len(ret) == 0 {
238+ panic("no return value specified for GetSnapshot")
239+ }
240+
241+ var r0 domain.MirrorSnapshot
242+ var r1 error
243+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.MirrorSnapshot, error)); ok {
244+ return returnFunc(ctx, repo)
245+ }
246+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.MirrorSnapshot); ok {
247+ r0 = returnFunc(ctx, repo)
248+ } else {
249+ r0 = ret.Get(0).(domain.MirrorSnapshot)
250+ }
251+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
252+ r1 = returnFunc(ctx, repo)
253+ } else {
254+ r1 = ret.Error(1)
255+ }
256+ return r0, r1
257+}
258+
259+// MirrorStore_GetSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSnapshot'
260+type MirrorStore_GetSnapshot_Call struct {
261+ *mock.Call
262+}
263+
264+// GetSnapshot is a helper method to define mock.On call
265+// - ctx context.Context
266+// - repo domain.RepoName
267+func (_e *MirrorStore_Expecter) GetSnapshot(ctx any, repo any) *MirrorStore_GetSnapshot_Call {
268+ return &MirrorStore_GetSnapshot_Call{Call: _e.mock.On("GetSnapshot", ctx, repo)}
269+}
270+
271+func (_c *MirrorStore_GetSnapshot_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorStore_GetSnapshot_Call {
272+ _c.Call.Run(func(args mock.Arguments) {
273+ var arg0 context.Context
274+ if args[0] != nil {
275+ arg0 = args[0].(context.Context)
276+ }
277+ var arg1 domain.RepoName
278+ if args[1] != nil {
279+ arg1 = args[1].(domain.RepoName)
280+ }
281+ run(
282+ arg0,
283+ arg1,
284+ )
285+ })
286+ return _c
287+}
288+
289+func (_c *MirrorStore_GetSnapshot_Call) Return(mirrorSnapshot domain.MirrorSnapshot, err error) *MirrorStore_GetSnapshot_Call {
290+ _c.Call.Return(mirrorSnapshot, err)
291+ return _c
292+}
293+
294+func (_c *MirrorStore_GetSnapshot_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error)) *MirrorStore_GetSnapshot_Call {
295+ _c.Call.Return(run)
296+ return _c
297+}
298+
299+// GetStatus provides a mock function for the type MirrorStore
300+func (_mock *MirrorStore) GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) {
301+ ret := _mock.Called(ctx, repo)
302+
303+ if len(ret) == 0 {
304+ panic("no return value specified for GetStatus")
305+ }
306+
307+ var r0 domain.MirrorStatus
308+ var r1 error
309+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (domain.MirrorStatus, error)); ok {
310+ return returnFunc(ctx, repo)
311+ }
312+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) domain.MirrorStatus); ok {
313+ r0 = returnFunc(ctx, repo)
314+ } else {
315+ r0 = ret.Get(0).(domain.MirrorStatus)
316+ }
317+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
318+ r1 = returnFunc(ctx, repo)
319+ } else {
320+ r1 = ret.Error(1)
321+ }
322+ return r0, r1
323+}
324+
325+// MirrorStore_GetStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStatus'
326+type MirrorStore_GetStatus_Call struct {
327+ *mock.Call
328+}
329+
330+// GetStatus is a helper method to define mock.On call
331+// - ctx context.Context
332+// - repo domain.RepoName
333+func (_e *MirrorStore_Expecter) GetStatus(ctx any, repo any) *MirrorStore_GetStatus_Call {
334+ return &MirrorStore_GetStatus_Call{Call: _e.mock.On("GetStatus", ctx, repo)}
335+}
336+
337+func (_c *MirrorStore_GetStatus_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorStore_GetStatus_Call {
338+ _c.Call.Run(func(args mock.Arguments) {
339+ var arg0 context.Context
340+ if args[0] != nil {
341+ arg0 = args[0].(context.Context)
342+ }
343+ var arg1 domain.RepoName
344+ if args[1] != nil {
345+ arg1 = args[1].(domain.RepoName)
346+ }
347+ run(
348+ arg0,
349+ arg1,
350+ )
351+ })
352+ return _c
353+}
354+
355+func (_c *MirrorStore_GetStatus_Call) Return(mirrorStatus domain.MirrorStatus, err error) *MirrorStore_GetStatus_Call {
356+ _c.Call.Return(mirrorStatus, err)
357+ return _c
358+}
359+
360+func (_c *MirrorStore_GetStatus_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error)) *MirrorStore_GetStatus_Call {
361+ _c.Call.Return(run)
362+ return _c
363+}
364+
365+// ListRepoConfigs provides a mock function for the type MirrorStore
366+func (_mock *MirrorStore) ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) {
367+ ret := _mock.Called(ctx)
368+
369+ if len(ret) == 0 {
370+ panic("no return value specified for ListRepoConfigs")
371+ }
372+
373+ var r0 []domain.MirrorRepoConfig
374+ var r1 error
375+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.MirrorRepoConfig, error)); ok {
376+ return returnFunc(ctx)
377+ }
378+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.MirrorRepoConfig); ok {
379+ r0 = returnFunc(ctx)
380+ } else {
381+ if ret.Get(0) != nil {
382+ r0 = ret.Get(0).([]domain.MirrorRepoConfig)
383+ }
384+ }
385+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
386+ r1 = returnFunc(ctx)
387+ } else {
388+ r1 = ret.Error(1)
389+ }
390+ return r0, r1
391+}
392+
393+// MirrorStore_ListRepoConfigs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRepoConfigs'
394+type MirrorStore_ListRepoConfigs_Call struct {
395+ *mock.Call
396+}
397+
398+// ListRepoConfigs is a helper method to define mock.On call
399+// - ctx context.Context
400+func (_e *MirrorStore_Expecter) ListRepoConfigs(ctx any) *MirrorStore_ListRepoConfigs_Call {
401+ return &MirrorStore_ListRepoConfigs_Call{Call: _e.mock.On("ListRepoConfigs", ctx)}
402+}
403+
404+func (_c *MirrorStore_ListRepoConfigs_Call) Run(run func(ctx context.Context)) *MirrorStore_ListRepoConfigs_Call {
405+ _c.Call.Run(func(args mock.Arguments) {
406+ var arg0 context.Context
407+ if args[0] != nil {
408+ arg0 = args[0].(context.Context)
409+ }
410+ run(
411+ arg0,
412+ )
413+ })
414+ return _c
415+}
416+
417+func (_c *MirrorStore_ListRepoConfigs_Call) Return(mirrorRepoConfigs []domain.MirrorRepoConfig, err error) *MirrorStore_ListRepoConfigs_Call {
418+ _c.Call.Return(mirrorRepoConfigs, err)
419+ return _c
420+}
421+
422+func (_c *MirrorStore_ListRepoConfigs_Call) RunAndReturn(run func(ctx context.Context) ([]domain.MirrorRepoConfig, error)) *MirrorStore_ListRepoConfigs_Call {
423+ _c.Call.Return(run)
424+ return _c
425+}
426+
427+// ListStatus provides a mock function for the type MirrorStore
428+func (_mock *MirrorStore) ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) {
429+ ret := _mock.Called(ctx)
430+
431+ if len(ret) == 0 {
432+ panic("no return value specified for ListStatus")
433+ }
434+
435+ var r0 []domain.MirrorStatus
436+ var r1 error
437+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.MirrorStatus, error)); ok {
438+ return returnFunc(ctx)
439+ }
440+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.MirrorStatus); ok {
441+ r0 = returnFunc(ctx)
442+ } else {
443+ if ret.Get(0) != nil {
444+ r0 = ret.Get(0).([]domain.MirrorStatus)
445+ }
446+ }
447+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
448+ r1 = returnFunc(ctx)
449+ } else {
450+ r1 = ret.Error(1)
451+ }
452+ return r0, r1
453+}
454+
455+// MirrorStore_ListStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListStatus'
456+type MirrorStore_ListStatus_Call struct {
457+ *mock.Call
458+}
459+
460+// ListStatus is a helper method to define mock.On call
461+// - ctx context.Context
462+func (_e *MirrorStore_Expecter) ListStatus(ctx any) *MirrorStore_ListStatus_Call {
463+ return &MirrorStore_ListStatus_Call{Call: _e.mock.On("ListStatus", ctx)}
464+}
465+
466+func (_c *MirrorStore_ListStatus_Call) Run(run func(ctx context.Context)) *MirrorStore_ListStatus_Call {
467+ _c.Call.Run(func(args mock.Arguments) {
468+ var arg0 context.Context
469+ if args[0] != nil {
470+ arg0 = args[0].(context.Context)
471+ }
472+ run(
473+ arg0,
474+ )
475+ })
476+ return _c
477+}
478+
479+func (_c *MirrorStore_ListStatus_Call) Return(mirrorStatuss []domain.MirrorStatus, err error) *MirrorStore_ListStatus_Call {
480+ _c.Call.Return(mirrorStatuss, err)
481+ return _c
482+}
483+
484+func (_c *MirrorStore_ListStatus_Call) RunAndReturn(run func(ctx context.Context) ([]domain.MirrorStatus, error)) *MirrorStore_ListStatus_Call {
485+ _c.Call.Return(run)
486+ return _c
487+}
488+
489+// PutGlobal provides a mock function for the type MirrorStore
490+func (_mock *MirrorStore) PutGlobal(ctx context.Context, g domain.MirrorGlobalConfig) error {
491+ ret := _mock.Called(ctx, g)
492+
493+ if len(ret) == 0 {
494+ panic("no return value specified for PutGlobal")
495+ }
496+
497+ var r0 error
498+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MirrorGlobalConfig) error); ok {
499+ r0 = returnFunc(ctx, g)
500+ } else {
501+ r0 = ret.Error(0)
502+ }
503+ return r0
504+}
505+
506+// MirrorStore_PutGlobal_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutGlobal'
507+type MirrorStore_PutGlobal_Call struct {
508+ *mock.Call
509+}
510+
511+// PutGlobal is a helper method to define mock.On call
512+// - ctx context.Context
513+// - g domain.MirrorGlobalConfig
514+func (_e *MirrorStore_Expecter) PutGlobal(ctx any, g any) *MirrorStore_PutGlobal_Call {
515+ return &MirrorStore_PutGlobal_Call{Call: _e.mock.On("PutGlobal", ctx, g)}
516+}
517+
518+func (_c *MirrorStore_PutGlobal_Call) Run(run func(ctx context.Context, g domain.MirrorGlobalConfig)) *MirrorStore_PutGlobal_Call {
519+ _c.Call.Run(func(args mock.Arguments) {
520+ var arg0 context.Context
521+ if args[0] != nil {
522+ arg0 = args[0].(context.Context)
523+ }
524+ var arg1 domain.MirrorGlobalConfig
525+ if args[1] != nil {
526+ arg1 = args[1].(domain.MirrorGlobalConfig)
527+ }
528+ run(
529+ arg0,
530+ arg1,
531+ )
532+ })
533+ return _c
534+}
535+
536+func (_c *MirrorStore_PutGlobal_Call) Return(err error) *MirrorStore_PutGlobal_Call {
537+ _c.Call.Return(err)
538+ return _c
539+}
540+
541+func (_c *MirrorStore_PutGlobal_Call) RunAndReturn(run func(ctx context.Context, g domain.MirrorGlobalConfig) error) *MirrorStore_PutGlobal_Call {
542+ _c.Call.Return(run)
543+ return _c
544+}
545+
546+// PutRepoConfig provides a mock function for the type MirrorStore
547+func (_mock *MirrorStore) PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error {
548+ ret := _mock.Called(ctx, c)
549+
550+ if len(ret) == 0 {
551+ panic("no return value specified for PutRepoConfig")
552+ }
553+
554+ var r0 error
555+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MirrorRepoConfig) error); ok {
556+ r0 = returnFunc(ctx, c)
557+ } else {
558+ r0 = ret.Error(0)
559+ }
560+ return r0
561+}
562+
563+// MirrorStore_PutRepoConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutRepoConfig'
564+type MirrorStore_PutRepoConfig_Call struct {
565+ *mock.Call
566+}
567+
568+// PutRepoConfig is a helper method to define mock.On call
569+// - ctx context.Context
570+// - c domain.MirrorRepoConfig
571+func (_e *MirrorStore_Expecter) PutRepoConfig(ctx any, c any) *MirrorStore_PutRepoConfig_Call {
572+ return &MirrorStore_PutRepoConfig_Call{Call: _e.mock.On("PutRepoConfig", ctx, c)}
573+}
574+
575+func (_c *MirrorStore_PutRepoConfig_Call) Run(run func(ctx context.Context, c domain.MirrorRepoConfig)) *MirrorStore_PutRepoConfig_Call {
576+ _c.Call.Run(func(args mock.Arguments) {
577+ var arg0 context.Context
578+ if args[0] != nil {
579+ arg0 = args[0].(context.Context)
580+ }
581+ var arg1 domain.MirrorRepoConfig
582+ if args[1] != nil {
583+ arg1 = args[1].(domain.MirrorRepoConfig)
584+ }
585+ run(
586+ arg0,
587+ arg1,
588+ )
589+ })
590+ return _c
591+}
592+
593+func (_c *MirrorStore_PutRepoConfig_Call) Return(err error) *MirrorStore_PutRepoConfig_Call {
594+ _c.Call.Return(err)
595+ return _c
596+}
597+
598+func (_c *MirrorStore_PutRepoConfig_Call) RunAndReturn(run func(ctx context.Context, c domain.MirrorRepoConfig) error) *MirrorStore_PutRepoConfig_Call {
599+ _c.Call.Return(run)
600+ return _c
601+}
602+
603+// PutSnapshot provides a mock function for the type MirrorStore
604+func (_mock *MirrorStore) PutSnapshot(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error {
605+ ret := _mock.Called(ctx, repo, s)
606+
607+ if len(ret) == 0 {
608+ panic("no return value specified for PutSnapshot")
609+ }
610+
611+ var r0 error
612+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.MirrorSnapshot) error); ok {
613+ r0 = returnFunc(ctx, repo, s)
614+ } else {
615+ r0 = ret.Error(0)
616+ }
617+ return r0
618+}
619+
620+// MirrorStore_PutSnapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutSnapshot'
621+type MirrorStore_PutSnapshot_Call struct {
622+ *mock.Call
623+}
624+
625+// PutSnapshot is a helper method to define mock.On call
626+// - ctx context.Context
627+// - repo domain.RepoName
628+// - s domain.MirrorSnapshot
629+func (_e *MirrorStore_Expecter) PutSnapshot(ctx any, repo any, s any) *MirrorStore_PutSnapshot_Call {
630+ return &MirrorStore_PutSnapshot_Call{Call: _e.mock.On("PutSnapshot", ctx, repo, s)}
631+}
632+
633+func (_c *MirrorStore_PutSnapshot_Call) Run(run func(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot)) *MirrorStore_PutSnapshot_Call {
634+ _c.Call.Run(func(args mock.Arguments) {
635+ var arg0 context.Context
636+ if args[0] != nil {
637+ arg0 = args[0].(context.Context)
638+ }
639+ var arg1 domain.RepoName
640+ if args[1] != nil {
641+ arg1 = args[1].(domain.RepoName)
642+ }
643+ var arg2 domain.MirrorSnapshot
644+ if args[2] != nil {
645+ arg2 = args[2].(domain.MirrorSnapshot)
646+ }
647+ run(
648+ arg0,
649+ arg1,
650+ arg2,
651+ )
652+ })
653+ return _c
654+}
655+
656+func (_c *MirrorStore_PutSnapshot_Call) Return(err error) *MirrorStore_PutSnapshot_Call {
657+ _c.Call.Return(err)
658+ return _c
659+}
660+
661+func (_c *MirrorStore_PutSnapshot_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error) *MirrorStore_PutSnapshot_Call {
662+ _c.Call.Return(run)
663+ return _c
664+}
665+
666+// PutStatus provides a mock function for the type MirrorStore
667+func (_mock *MirrorStore) PutStatus(ctx context.Context, s domain.MirrorStatus) error {
668+ ret := _mock.Called(ctx, s)
669+
670+ if len(ret) == 0 {
671+ panic("no return value specified for PutStatus")
672+ }
673+
674+ var r0 error
675+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.MirrorStatus) error); ok {
676+ r0 = returnFunc(ctx, s)
677+ } else {
678+ r0 = ret.Error(0)
679+ }
680+ return r0
681+}
682+
683+// MirrorStore_PutStatus_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutStatus'
684+type MirrorStore_PutStatus_Call struct {
685+ *mock.Call
686+}
687+
688+// PutStatus is a helper method to define mock.On call
689+// - ctx context.Context
690+// - s domain.MirrorStatus
691+func (_e *MirrorStore_Expecter) PutStatus(ctx any, s any) *MirrorStore_PutStatus_Call {
692+ return &MirrorStore_PutStatus_Call{Call: _e.mock.On("PutStatus", ctx, s)}
693+}
694+
695+func (_c *MirrorStore_PutStatus_Call) Run(run func(ctx context.Context, s domain.MirrorStatus)) *MirrorStore_PutStatus_Call {
696+ _c.Call.Run(func(args mock.Arguments) {
697+ var arg0 context.Context
698+ if args[0] != nil {
699+ arg0 = args[0].(context.Context)
700+ }
701+ var arg1 domain.MirrorStatus
702+ if args[1] != nil {
703+ arg1 = args[1].(domain.MirrorStatus)
704+ }
705+ run(
706+ arg0,
707+ arg1,
708+ )
709+ })
710+ return _c
711+}
712+
713+func (_c *MirrorStore_PutStatus_Call) Return(err error) *MirrorStore_PutStatus_Call {
714+ _c.Call.Return(err)
715+ return _c
716+}
717+
718+func (_c *MirrorStore_PutStatus_Call) RunAndReturn(run func(ctx context.Context, s domain.MirrorStatus) error) *MirrorStore_PutStatus_Call {
719+ _c.Call.Return(run)
720+ return _c
721+}
A internal/ports/mocks/MirrorTrigger.go +100 -0
@@ -0,0 +1,100 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMirrorTrigger creates a new instance of MirrorTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMirrorTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MirrorTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MirrorTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MirrorTrigger is an autogenerated mock type for the MirrorTrigger type
38+type MirrorTrigger struct {
39+ mock.Mock
40+}
41+
42+type MirrorTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MirrorTrigger) EXPECT() *MirrorTrigger_Expecter {
47+ return &MirrorTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnPush provides a mock function for the type MirrorTrigger
51+func (_mock *MirrorTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate) {
52+ _mock.Called(ctx, repo, updates)
53+ return
54+}
55+
56+// MirrorTrigger_OnPush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnPush'
57+type MirrorTrigger_OnPush_Call struct {
58+ *mock.Call
59+}
60+
61+// OnPush is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+func (_e *MirrorTrigger_Expecter) OnPush(ctx any, repo any, updates any) *MirrorTrigger_OnPush_Call {
66+ return &MirrorTrigger_OnPush_Call{Call: _e.mock.On("OnPush", ctx, repo, updates)}
67+}
68+
69+func (_c *MirrorTrigger_OnPush_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MirrorTrigger_OnPush_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ var arg0 context.Context
72+ if args[0] != nil {
73+ arg0 = args[0].(context.Context)
74+ }
75+ var arg1 domain.RepoName
76+ if args[1] != nil {
77+ arg1 = args[1].(domain.RepoName)
78+ }
79+ var arg2 []domain.RefUpdate
80+ if args[2] != nil {
81+ arg2 = args[2].([]domain.RefUpdate)
82+ }
83+ run(
84+ arg0,
85+ arg1,
86+ arg2,
87+ )
88+ })
89+ return _c
90+}
91+
92+func (_c *MirrorTrigger_OnPush_Call) Return() *MirrorTrigger_OnPush_Call {
93+ _c.Call.Return()
94+ return _c
95+}
96+
97+func (_c *MirrorTrigger_OnPush_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate)) *MirrorTrigger_OnPush_Call {
98+ _c.Run(run)
99+ return _c
100+}
A internal/ports/mocks/MirrorWarner.go +183 -0
@@ -0,0 +1,183 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMirrorWarner creates a new instance of MirrorWarner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMirrorWarner(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MirrorWarner {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &MirrorWarner{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// MirrorWarner is an autogenerated mock type for the MirrorWarner type
38+type MirrorWarner struct {
39+ mock.Mock
40+}
41+
42+type MirrorWarner_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *MirrorWarner) EXPECT() *MirrorWarner_Expecter {
47+ return &MirrorWarner_Expecter{mock: &_m.Mock}
48+}
49+
50+// Badge provides a mock function for the type MirrorWarner
51+func (_mock *MirrorWarner) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {
52+ ret := _mock.Called(ctx, p, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for Badge")
56+ }
57+
58+ var r0 domain.MirrorState
59+ var r1 string
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (domain.MirrorState, string)); ok {
61+ return returnFunc(ctx, p, repo)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) domain.MirrorState); ok {
64+ r0 = returnFunc(ctx, p, repo)
65+ } else {
66+ r0 = ret.Get(0).(domain.MirrorState)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) string); ok {
69+ r1 = returnFunc(ctx, p, repo)
70+ } else {
71+ r1 = ret.Get(1).(string)
72+ }
73+ return r0, r1
74+}
75+
76+// MirrorWarner_Badge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Badge'
77+type MirrorWarner_Badge_Call struct {
78+ *mock.Call
79+}
80+
81+// Badge is a helper method to define mock.On call
82+// - ctx context.Context
83+// - p domain.Principal
84+// - repo domain.RepoName
85+func (_e *MirrorWarner_Expecter) Badge(ctx any, p any, repo any) *MirrorWarner_Badge_Call {
86+ return &MirrorWarner_Badge_Call{Call: _e.mock.On("Badge", ctx, p, repo)}
87+}
88+
89+func (_c *MirrorWarner_Badge_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorWarner_Badge_Call {
90+ _c.Call.Run(func(args mock.Arguments) {
91+ var arg0 context.Context
92+ if args[0] != nil {
93+ arg0 = args[0].(context.Context)
94+ }
95+ var arg1 domain.Principal
96+ if args[1] != nil {
97+ arg1 = args[1].(domain.Principal)
98+ }
99+ var arg2 domain.RepoName
100+ if args[2] != nil {
101+ arg2 = args[2].(domain.RepoName)
102+ }
103+ run(
104+ arg0,
105+ arg1,
106+ arg2,
107+ )
108+ })
109+ return _c
110+}
111+
112+func (_c *MirrorWarner_Badge_Call) Return(mirrorState domain.MirrorState, s string) *MirrorWarner_Badge_Call {
113+ _c.Call.Return(mirrorState, s)
114+ return _c
115+}
116+
117+func (_c *MirrorWarner_Badge_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string)) *MirrorWarner_Badge_Call {
118+ _c.Call.Return(run)
119+ return _c
120+}
121+
122+// Warning provides a mock function for the type MirrorWarner
123+func (_mock *MirrorWarner) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
124+ ret := _mock.Called(ctx, p, repo)
125+
126+ if len(ret) == 0 {
127+ panic("no return value specified for Warning")
128+ }
129+
130+ var r0 string
131+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) string); ok {
132+ r0 = returnFunc(ctx, p, repo)
133+ } else {
134+ r0 = ret.Get(0).(string)
135+ }
136+ return r0
137+}
138+
139+// MirrorWarner_Warning_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Warning'
140+type MirrorWarner_Warning_Call struct {
141+ *mock.Call
142+}
143+
144+// Warning is a helper method to define mock.On call
145+// - ctx context.Context
146+// - p domain.Principal
147+// - repo domain.RepoName
148+func (_e *MirrorWarner_Expecter) Warning(ctx any, p any, repo any) *MirrorWarner_Warning_Call {
149+ return &MirrorWarner_Warning_Call{Call: _e.mock.On("Warning", ctx, p, repo)}
150+}
151+
152+func (_c *MirrorWarner_Warning_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorWarner_Warning_Call {
153+ _c.Call.Run(func(args mock.Arguments) {
154+ var arg0 context.Context
155+ if args[0] != nil {
156+ arg0 = args[0].(context.Context)
157+ }
158+ var arg1 domain.Principal
159+ if args[1] != nil {
160+ arg1 = args[1].(domain.Principal)
161+ }
162+ var arg2 domain.RepoName
163+ if args[2] != nil {
164+ arg2 = args[2].(domain.RepoName)
165+ }
166+ run(
167+ arg0,
168+ arg1,
169+ arg2,
170+ )
171+ })
172+ return _c
173+}
174+
175+func (_c *MirrorWarner_Warning_Call) Return(s string) *MirrorWarner_Warning_Call {
176+ _c.Call.Return(s)
177+ return _c
178+}
179+
180+func (_c *MirrorWarner_Warning_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) string) *MirrorWarner_Warning_Call {
181+ _c.Call.Return(run)
182+ return _c
183+}
M internal/usecase/events.go +8 -0
@@ -167,6 +167,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
167167 return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
168168 case domain.RepoDeleted:
169169 return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt}
170+ case domain.MirrorFailed:
171+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
172+ case domain.MirrorRecovered:
173+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
170174 case domain.PushEvent:
171175 if ev.CreatedAt.IsZero() {
172176 return bufferedEvent{} // repository unknown at publish time: nobody is told
@@ -517,6 +521,10 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
517521 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
518522 case domain.RepoUpdated:
519523 return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send)
524+ case domain.MirrorFailed:
525+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
526+ case domain.MirrorRecovered:
527+ return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
520528 case domain.PushEvent:
521529 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
522530 case domain.MergeRequestOpened:
M internal/usecase/mergerequests.go +10 -0
@@ -58,6 +58,7 @@ type MergeRequests struct {
5858 tips ports.CIRefTipStore // nil: who merges is not recorded
5959 verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030)
6060 mergeab MergeabilityHook // nil: no mergeability
61+ mirror ports.MirrorTrigger // nil: no outbound mirror
6162
6263 mu sync.Mutex
6364 locks map[domain.RepoName]chan struct{}
@@ -116,6 +117,12 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
116117 }
117118 }
118119
120+// WithMirror makes a merge notify outbound mirrors. Call before the first merge.
121+func (m *MergeRequests) WithMirror(t ports.MirrorTrigger) *MergeRequests {
122+ m.mirror = t
123+ return m
124+}
125+
119126 // WithVerifier makes GetMergeRequest fill the signature state of the commits
120127 // it lists (ADR 0030).
121128 func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests {
@@ -1172,6 +1179,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo
11721179 m.bus.Publish(ctx, domain.PushEvent{
11731180 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
11741181 })
1182+ if m.mirror != nil {
1183+ m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks
1184+ }
11751185 if m.pulls != nil {
11761186 m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p)
11771187 }
A internal/usecase/mirrors.go +91 -0
@@ -0,0 +1,91 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "sync"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+const mirrorQueueSize = 64
16+
17+// MirrorsConfig supplies the mirror use case's dependencies.
18+type MirrorsConfig struct {
19+ Store ports.MirrorStore
20+ Keys ports.MirrorKeyStore
21+ Sealer ports.SecretSealer
22+ Pusher ports.MirrorPusher
23+ Meta ports.RepoMetaStore
24+ Git ports.GitRepos
25+ Reader ports.RepoReader
26+ Bus ports.EventBus
27+ Clock ports.Clock
28+ PublicURL string
29+ ScheduleTick time.Duration
30+ Workers int
31+ QueueSize int // jobs waiting for a worker; default 64
32+ // ValidateURL checks a resolved mirror URL; default domain.ValidateMirrorURL. Tests of
33+ // local transports inject a permissive check.
34+ ValidateURL func(string) error
35+}
36+
37+// mirrorGen counts the changes of a repository (dirty) and the count the last successful
38+// sync saw (clean, meaningful once cleanKnown).
39+type mirrorGen struct {
40+ dirty, clean uint64
41+ cleanKnown bool
42+}
43+
44+// Mirrors manages outbound repository mirrors.
45+type Mirrors struct {
46+ cfg MirrorsConfig
47+ configMu sync.RWMutex // configuration changes; held for reads only around store reads and writes, never during a push
48+ mu sync.Mutex
49+ gens map[domain.RepoName]mirrorGen
50+ flights map[domain.RepoName]*mirrorFlight
51+ flightWG sync.WaitGroup // one per flight, for tests that wait for the queue to drain
52+ ctx context.Context
53+ cancel context.CancelFunc
54+ wg sync.WaitGroup
55+ start sync.Once
56+ closed bool
57+ jobs chan domain.RepoName
58+
59+ scheduleMu sync.Mutex
60+ lastMinute time.Time
61+ deferred map[domain.RepoName]struct{} // due, but the queue was full
62+
63+ keyMu sync.Mutex
64+ key ed25519.PrivateKey
65+ keyCreated time.Time
66+}
67+
68+// NewMirrors builds the mirror use case.
69+func NewMirrors(cfg MirrorsConfig) *Mirrors {
70+ if cfg.ScheduleTick <= 0 {
71+ cfg.ScheduleTick = time.Second
72+ }
73+ if cfg.Workers <= 0 {
74+ cfg.Workers = 2
75+ }
76+ if cfg.QueueSize <= 0 {
77+ cfg.QueueSize = mirrorQueueSize
78+ }
79+ if cfg.ValidateURL == nil {
80+ cfg.ValidateURL = domain.ValidateMirrorURL
81+ }
82+ ctx, cancel := context.WithCancel(context.Background())
83+ return &Mirrors{
84+ cfg: cfg, ctx: ctx, cancel: cancel,
85+ gens: make(map[domain.RepoName]mirrorGen),
86+ flights: make(map[domain.RepoName]*mirrorFlight),
87+ deferred: make(map[domain.RepoName]struct{}),
88+ jobs: make(chan domain.RepoName, cfg.QueueSize),
89+ lastMinute: cfg.Clock.Now().UTC().Truncate(time.Minute),
90+ }
91+}
A internal/usecase/mirrors_config.go +365 -0
@@ -0,0 +1,365 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+func mirrorOwner(p domain.Principal) error {
15+ if p.IsAnonymous() {
16+ return domain.ErrUnauthenticated
17+ }
18+ if !p.HasOwnerRights() {
19+ return domain.ErrForbidden
20+ }
21+ return nil
22+}
23+func (m *Mirrors) admin(ctx context.Context, p domain.Principal, repo domain.RepoName) error {
24+ if p.IsAnonymous() {
25+ return domain.ErrUnauthenticated
26+ }
27+ r, err := m.cfg.Meta.Get(ctx, repo)
28+ if err != nil {
29+ return err
30+ }
31+ if !p.Allows(domain.ScopeRepoAdmin, repo) {
32+ if !canRead(p, r) {
33+ return domain.ErrNotFound
34+ }
35+ return domain.ErrForbidden
36+ }
37+ return nil
38+}
39+func (m *Mirrors) target(ctx context.Context, repo domain.RepoName) (domain.MirrorTarget, error) {
40+ g, err := m.cfg.Store.GetGlobal(ctx)
41+ if err != nil {
42+ return domain.MirrorTarget{}, err
43+ }
44+ r, err := m.cfg.Store.GetRepoConfig(ctx, repo)
45+ if errors.Is(err, domain.ErrNotFound) {
46+ return m.resolve(g, nil, repo)
47+ }
48+ if err != nil {
49+ return domain.MirrorTarget{}, err
50+ }
51+ return m.resolve(g, &r, repo)
52+}
53+func (m *Mirrors) status(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) {
54+ s, err := m.cfg.Store.GetStatus(ctx, repo)
55+ if errors.Is(err, domain.ErrNotFound) {
56+ return domain.MirrorStatus{Repo: repo, State: domain.MirrorDisabled}, nil
57+ }
58+ return s, err
59+}
60+
61+// GetGlobalConfig returns server-wide policy to an owner.
62+func (m *Mirrors) GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error) {
63+ if err := mirrorOwner(p); err != nil {
64+ return domain.MirrorGlobalConfig{}, err
65+ }
66+ return m.cfg.Store.GetGlobal(ctx)
67+}
68+
69+// SetGlobalConfig patches server-wide policy and resets affected mirror incidents.
70+func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error) {
71+ if err := mirrorOwner(p); err != nil {
72+ return domain.MirrorGlobalConfig{}, err
73+ }
74+ m.configMu.Lock()
75+ defer m.configMu.Unlock()
76+ old, err := m.cfg.Store.GetGlobal(ctx)
77+ if err != nil {
78+ return old, err
79+ }
80+ g := old
81+ if patch.Enabled != nil {
82+ g.Enabled = *patch.Enabled
83+ }
84+ if patch.URLTemplate != nil {
85+ g.URLTemplate = *patch.URLTemplate
86+ }
87+ if patch.Cron != nil {
88+ g.Cron = *patch.Cron
89+ }
90+ if patch.AllBranches != nil {
91+ g.AllBranches = *patch.AllBranches
92+ }
93+ if err := m.validateGlobal(g); err != nil {
94+ return old, err
95+ }
96+ repos, err := m.cfg.Meta.List(ctx)
97+ if err != nil {
98+ return old, err
99+ }
100+ // Resolve every effective target before and after, and persist nothing
101+ // unless all of them stay valid.
102+ var changed []domain.RepoName
103+ for _, r := range repos {
104+ c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name)
105+ var cp *domain.MirrorRepoConfig
106+ if e == nil {
107+ cp = &c
108+ } else if !errors.Is(e, domain.ErrNotFound) {
109+ return old, e
110+ }
111+ before, _ := m.resolve(old, cp, r.Name)
112+ after, e := m.resolve(g, cp, r.Name)
113+ if e != nil {
114+ return old, invalidCron(e)
115+ }
116+ if !sameMirrorTarget(before, after) {
117+ changed = append(changed, r.Name)
118+ }
119+ }
120+ g.UpdatedAt = m.cfg.Clock.Now().UTC()
121+ if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
122+ return old, err
123+ }
124+ // One failing reset must not leave the others stale: the policy is saved.
125+ var errs []error
126+ for _, name := range changed {
127+ if err := m.reset(ctx, name); err != nil {
128+ errs = append(errs, err)
129+ }
130+ }
131+ return g, errors.Join(errs...)
132+}
133+
134+func invalidCron(e error) error {
135+ var inv *domain.InvalidFieldsError
136+ if errors.As(e, &inv) {
137+ return e
138+ }
139+ return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "cron", Reason: e.Error()}}}
140+}
141+
142+func sameMirrorTarget(a, b domain.MirrorTarget) bool {
143+ return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
144+}
145+
146+// reset lifts the incident of a repository whose effective target changed and marks it dirty.
147+func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
148+ t, err := m.target(ctx, repo)
149+ if err != nil {
150+ return err
151+ }
152+ s, err := m.status(ctx, repo)
153+ if err != nil {
154+ return err
155+ }
156+ now := m.cfg.Clock.Now().UTC()
157+ if t.Enabled {
158+ s = domain.MirrorReset(s, now)
159+ } else {
160+ s = domain.MirrorDisable(s, now)
161+ }
162+ m.mu.Lock()
163+ g := m.gens[repo]
164+ g.dirty++
165+ m.gens[repo] = g
166+ m.mu.Unlock()
167+ return m.cfg.Store.PutStatus(ctx, s)
168+}
169+
170+// resetIfChanged resets repo when its effective target is no longer before. configMu is held.
171+func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) {
172+ after, err := m.target(ctx, repo)
173+ if err != nil {
174+ return after, err
175+ }
176+ if beforeErr == nil && sameMirrorTarget(before, after) {
177+ return after, nil
178+ }
179+ return after, m.reset(ctx, repo)
180+}
181+
182+// SetRepoMirror sets a repository override.
183+func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
184+ if err := m.admin(ctx, p, c.Repo); err != nil {
185+ return domain.MirrorTarget{}, err
186+ }
187+ if err := m.validateRepo(c); err != nil {
188+ return domain.MirrorTarget{}, err
189+ }
190+ m.configMu.Lock()
191+ defer m.configMu.Unlock()
192+ return m.setRepoLocked(ctx, c)
193+}
194+
195+// setRepoLocked validates the prospective target, then persists. configMu must be held.
196+func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
197+ g, err := m.cfg.Store.GetGlobal(ctx)
198+ if err != nil {
199+ return domain.MirrorTarget{}, err
200+ }
201+ if _, err := m.resolve(g, &c, c.Repo); err != nil {
202+ return domain.MirrorTarget{}, invalidCron(err)
203+ }
204+ before, beforeErr := m.target(ctx, c.Repo)
205+ c.UpdatedAt = m.cfg.Clock.Now().UTC()
206+ if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
207+ return domain.MirrorTarget{}, err
208+ }
209+ return m.resetIfChanged(ctx, c.Repo, before, beforeErr)
210+}
211+
212+// RemoveRepoMirror restores inherited policy.
213+func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error {
214+ if err := m.admin(ctx, p, repo); err != nil {
215+ return err
216+ }
217+ m.configMu.Lock()
218+ defer m.configMu.Unlock()
219+ before, beforeErr := m.target(ctx, repo)
220+ if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil {
221+ return err
222+ }
223+ _, err := m.resetIfChanged(ctx, repo, before, beforeErr)
224+ return err
225+}
226+
227+// SetRepoExcluded updates exclusion without discarding an override.
228+func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error) {
229+ if err := m.admin(ctx, p, repo); err != nil {
230+ return domain.MirrorTarget{}, err
231+ }
232+ m.configMu.Lock()
233+ defer m.configMu.Unlock()
234+ c, err := m.cfg.Store.GetRepoConfig(ctx, repo)
235+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
236+ return domain.MirrorTarget{}, err
237+ }
238+ c.Repo, c.Excluded = repo, excluded
239+ return m.setRepoLocked(ctx, c)
240+}
241+
242+// GetStatus returns a read-authorized status, redacting administrative diagnostics.
243+func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {
244+ return m.statusView(ctx, p, repo, true)
245+}
246+
247+// statusView builds the view of one repository; withNext computes the next scheduled run.
248+func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) {
249+ r, err := m.cfg.Meta.Get(ctx, repo)
250+ if err != nil {
251+ return ports.MirrorStatusView{}, err
252+ }
253+ if !canRead(p, r) {
254+ return ports.MirrorStatusView{}, domain.ErrNotFound
255+ }
256+ t, err := m.target(ctx, repo)
257+ if err != nil {
258+ return ports.MirrorStatusView{}, err
259+ }
260+ s, err := m.status(ctx, repo)
261+ if err != nil {
262+ return ports.MirrorStatusView{}, err
263+ }
264+ return m.view(p, t, s, withNext), nil
265+}
266+
267+func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView {
268+ var next time.Time
269+ if withNext && t.Enabled && s.State != domain.MirrorPaused {
270+ if s.State == domain.MirrorFailing {
271+ next = s.NextRetryAt
272+ } else {
273+ next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now())
274+ }
275+ }
276+ if !p.Allows(domain.ScopeRepoAdmin, t.Repo) {
277+ s.Detail, s.Reason = "", ""
278+ t.URL = ""
279+ }
280+ return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}
281+}
282+
283+// ListStatus returns all statuses to an owner, from three reads instead of three per repository.
284+func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {
285+ if err := mirrorOwner(p); err != nil {
286+ return nil, err
287+ }
288+ repos, err := m.cfg.Meta.List(ctx)
289+ if err != nil {
290+ return nil, err
291+ }
292+ g, err := m.cfg.Store.GetGlobal(ctx)
293+ if err != nil {
294+ return nil, err
295+ }
296+ configs, err := m.cfg.Store.ListRepoConfigs(ctx)
297+ if err != nil {
298+ return nil, err
299+ }
300+ byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs))
301+ for _, c := range configs {
302+ byRepo[c.Repo] = c
303+ }
304+ statuses, err := m.cfg.Store.ListStatus(ctx)
305+ if err != nil {
306+ return nil, err
307+ }
308+ stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses))
309+ for _, s := range statuses {
310+ stored[s.Repo] = s
311+ }
312+ out := make([]ports.MirrorStatusView, 0, len(repos))
313+ for _, r := range repos {
314+ var cp *domain.MirrorRepoConfig
315+ if c, ok := byRepo[r.Name]; ok {
316+ cp = &c
317+ }
318+ t, err := m.resolve(g, cp, r.Name)
319+ if err != nil {
320+ return nil, err
321+ }
322+ s, ok := stored[r.Name]
323+ if !ok {
324+ s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled}
325+ }
326+ out = append(out, m.view(p, t, s, true))
327+ }
328+ return out, nil
329+}
330+
331+// Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of
332+// the repository only: it names the remote and the reason.
333+func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
334+ if !p.Allows(domain.ScopeRepoAdmin, repo) {
335+ return ""
336+ }
337+ s, err := m.status(ctx, repo)
338+ if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) {
339+ return ""
340+ }
341+ t, err := m.target(ctx, repo)
342+ if err != nil {
343+ return ""
344+ }
345+ return "ermite: mirror to " + domain.RedactMirrorURL(t.URL) + " is " + string(s.State) + ": " + s.Reason
346+}
347+
348+// Badge returns a viewer-safe state and optional administrative reason.
349+func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {
350+ v, err := m.statusView(ctx, p, repo, false)
351+ if err != nil {
352+ return domain.MirrorDisabled, ""
353+ }
354+ return v.Status.State, v.Status.Reason
355+}
356+
357+func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error {
358+ return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL)
359+}
360+func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error {
361+ return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL)
362+}
363+func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) {
364+ return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL)
365+}
A internal/usecase/mirrors_config_test.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "errors"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+func TestMirrorsAuthorizationMatrix(t *testing.T) {
20+ f := newMirrorFixture(t)
21+ ctx := context.Background()
22+ reader := tokenPrincipal(domain.ScopeRepoRead)
23+ admin := tokenPrincipal(domain.ScopeRepoAdmin)
24+ _, err := f.m.GetGlobalConfig(ctx, reader)
25+ require.ErrorIs(t, err, domain.ErrForbidden)
26+ _, err = f.m.SetGlobalConfig(ctx, reader, ports.MirrorGlobalPatch{})
27+ require.ErrorIs(t, err, domain.ErrForbidden)
28+ _, err = f.m.ListStatus(ctx, reader)
29+ require.ErrorIs(t, err, domain.ErrForbidden)
30+ _, err = f.m.RotateKey(ctx, admin)
31+ require.ErrorIs(t, err, domain.ErrForbidden)
32+ // Key show deliberately requires authentication, not owner rights.
33+ _, err = f.m.ShowKey(ctx, reader)
34+ require.NoError(t, err)
35+ _, err = f.m.ShowKey(ctx, domain.Anonymous())
36+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
37+ _, err = f.m.SetRepoMirror(ctx, reader, domain.MirrorRepoConfig{Repo: f.repo})
38+ require.ErrorIs(t, err, domain.ErrForbidden)
39+ err = f.m.RemoveRepoMirror(ctx, reader, f.repo)
40+ require.ErrorIs(t, err, domain.ErrForbidden)
41+ _, err = f.m.SetRepoExcluded(ctx, reader, f.repo, true)
42+ require.ErrorIs(t, err, domain.ErrForbidden)
43+ _, err = f.m.Sync(ctx, reader, f.repo, false)
44+ require.ErrorIs(t, err, domain.ErrForbidden)
45+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK, Detail: "admin detail", Reason: "admin reason"})
46+ view, err := f.m.GetStatus(ctx, domain.Anonymous(), f.repo)
47+ require.NoError(t, err)
48+ require.Empty(t, view.Status.Detail)
49+ require.Empty(t, view.Status.Reason)
50+ require.Empty(t, view.Target.URL)
51+ view, err = f.m.GetStatus(ctx, admin, f.repo)
52+ require.NoError(t, err)
53+ require.Equal(t, "admin detail", view.Status.Detail)
54+ _, err = f.m.SetRepoMirror(ctx, admin, domain.MirrorRepoConfig{Repo: f.repo})
55+ require.NoError(t, err)
56+}
57+
58+func TestMirrorsPrivateStatusAuthorization(t *testing.T) {
59+ f := newMirrorFixture(t)
60+ f.mu.Lock()
61+ f.repos[0].Visibility = domain.VisibilityPrivate
62+ f.mu.Unlock()
63+ _, err := f.m.GetStatus(context.Background(), domain.Anonymous(), f.repo)
64+ require.ErrorIs(t, err, domain.ErrNotFound)
65+ _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "other"), f.repo)
66+ require.ErrorIs(t, err, domain.ErrNotFound)
67+ _, err = f.m.GetStatus(context.Background(), tokenPrincipal(domain.ScopeRepoRead, "tools"), f.repo)
68+ require.NoError(t, err)
69+}
70+
71+func TestMirrorsWarningIsAdminOnly(t *testing.T) {
72+ f := newMirrorFixture(t)
73+ other := name(t, "other")
74+ failing := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, Reason: "the remote has commits"}
75+ for _, tc := range []struct {
76+ name string
77+ p domain.Principal
78+ status domain.MirrorStatus
79+ want bool
80+ }{
81+ {"root", domain.Root(), failing, true},
82+ {"repo admin", tokenPrincipal(domain.ScopeRepoAdmin, "tools"), failing, true},
83+ {"admin of another repository", tokenPrincipal(domain.ScopeRepoAdmin, other.String()), failing, false},
84+ {"writer", tokenPrincipal(domain.ScopeRepoWrite, "tools"), failing, false},
85+ {"anonymous", domain.Anonymous(), failing, false},
86+ {"admin, mirror ok", domain.Root(), domain.MirrorStatus{Repo: f.repo, State: domain.MirrorOK}, false},
87+ } {
88+ t.Run(tc.name, func(t *testing.T) {
89+ f.setStatus(tc.status)
90+ w := f.m.Warning(context.Background(), tc.p, f.repo)
91+ if !tc.want {
92+ require.Empty(t, w)
93+ return
94+ }
95+ require.Contains(t, w, "example.com:acme/tools")
96+ require.Contains(t, w, "paused")
97+ require.Contains(t, w, "the remote has commits")
98+ })
99+ }
100+}
101+
102+func TestMirrorsBadge(t *testing.T) {
103+ f := newMirrorFixture(t)
104+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Reason: "retrying"})
105+ state, reason := f.m.Badge(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin, "tools"), f.repo)
106+ require.Equal(t, domain.MirrorFailing, state)
107+ require.Equal(t, "retrying", reason)
108+ state, reason = f.m.Badge(context.Background(), domain.Anonymous(), f.repo)
109+ require.Equal(t, domain.MirrorFailing, state)
110+ require.Empty(t, reason, "the reason is for admins")
111+ state, _ = f.m.Badge(context.Background(), domain.Root(), name(t, "ghost"))
112+ require.Equal(t, domain.MirrorDisabled, state)
113+ // A badge needs no schedule: with an unparsable-looking cron the view still works and costs no search.
114+ require.NotPanics(t, func() { f.m.Badge(context.Background(), domain.Root(), f.repo) })
115+}
116+
117+func TestMirrorsListStatusReadsOnceAndHidesNothingFromOwner(t *testing.T) {
118+ f := newMirrorFixture(t)
119+ second := name(t, "second")
120+ f.addRepo(second)
121+ f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, Reason: "r"})
122+ list, err := f.m.ListStatus(context.Background(), domain.Root())
123+ require.NoError(t, err)
124+ require.Len(t, list, 2)
125+ require.Equal(t, "git@example.com:acme/tools", list[0].Target.URL)
126+ require.Equal(t, domain.MirrorPaused, list[1].Status.State)
127+ require.False(t, list[0].NextRunAt.IsZero())
128+ require.True(t, list[1].NextRunAt.IsZero(), "a paused mirror has no next run")
129+}
130+
131+func TestMirrorsDivergedPausedAndConfigReset(t *testing.T) {
132+ f := newMirrorFixture(t)
133+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1})
134+ _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:acme/tools", Cron: "*/5 * * * *"})
135+ require.NoError(t, err)
136+ s := f.statusOf(f.repo)
137+ require.Equal(t, domain.MirrorOK, s.State)
138+ require.Zero(t, s.ConsecutiveFailures)
139+}
140+
141+func TestMirrorsRepoChangesResetOnlyWhenTheTargetChanged(t *testing.T) {
142+ f := newMirrorFixture(t)
143+ ctx := context.Background()
144+ paused := domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, ConsecutiveFailures: 1}
145+ f.setStatus(paused)
146+ // The override restates the inherited target: nothing changed, the incident stays.
147+ _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
148+ require.NoError(t, err) // source changes global -> override: a change
149+ f.setStatus(paused)
150+ writes := f.statusWrites()
151+ _, err = f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
152+ require.NoError(t, err)
153+ require.Equal(t, writes, f.statusWrites(), "same effective target: no reset")
154+ require.Equal(t, domain.MirrorPaused, f.statusOf(f.repo).State)
155+ // Removing a no-op exclusion, and excluding to the same effect, follow the same rule.
156+ err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo)
157+ require.NoError(t, err)
158+ require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State, "override gone: the source changed back to global")
159+ f.setStatus(paused)
160+ writes = f.statusWrites()
161+ err = f.m.RemoveRepoMirror(ctx, domain.Root(), f.repo)
162+ require.NoError(t, err)
163+ require.Equal(t, writes, f.statusWrites(), "no override to remove: no reset")
164+ _, err = f.m.SetRepoExcluded(ctx, domain.Root(), f.repo, true)
165+ require.NoError(t, err)
166+ require.Equal(t, domain.MirrorDisabled, f.statusOf(f.repo).State)
167+}
168+
169+func TestMirrorsGlobalResetContinuesPastErrors(t *testing.T) {
170+ f := newMirrorFixture(t)
171+ second := name(t, "second")
172+ f.addRepo(second)
173+ f.setStatus(domain.MirrorStatus{Repo: second, State: domain.MirrorPaused, ConsecutiveFailures: 1})
174+ f.failStatusWrites(f.repo, errBoom)
175+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
176+ require.ErrorIs(t, err, errBoom)
177+ require.Equal(t, domain.MirrorOK, f.statusOf(second).State, "the other repository was still reset")
178+ require.Equal(t, "*/10 * * * *", f.global.Cron, "the policy itself is saved")
179+}
180+
181+func TestMirrorsOverrideWithoutCronRejectedWhenGlobalCronEmpty(t *testing.T) {
182+ f := newMirrorFixture(t)
183+ f.setGlobal(domain.MirrorGlobalConfig{})
184+ _, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "git@example.com:acme/tools"})
185+ var fe *domain.InvalidFieldsError
186+ require.ErrorAs(t, err, &fe)
187+ require.Equal(t, "cron", fe.Fields[0].Field)
188+ require.Empty(t, f.cfgs, "nothing is stored")
189+}
190+
191+func TestMirrorsGlobalChangeInvalidatingOverrideRejected(t *testing.T) {
192+ f := newMirrorFixture(t)
193+ f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"})
194+ // Disabling the global with an empty cron leaves the inheriting override with no cron.
195+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Enabled: ptr(false), Cron: ptr("")})
196+ var fe *domain.InvalidFieldsError
197+ require.ErrorAs(t, err, &fe)
198+ require.True(t, f.global.Enabled, "nothing is stored")
199+}
200+
201+func TestMirrorsGlobalChangeResetsInheritingURLOverride(t *testing.T) {
202+ f := newMirrorFixture(t)
203+ f.setRepoConfig(domain.MirrorRepoConfig{Repo: f.repo, URL: "git@other.example:x/y"})
204+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged})
205+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
206+ require.NoError(t, err)
207+ require.Equal(t, domain.MirrorOK, f.statusOf(f.repo).State)
208+}
209+
210+func TestMirrorsSetRepoExcludedConcurrentIsSerialized(t *testing.T) {
211+ f := newMirrorFixture(t)
212+ var mu sync.Mutex
213+ active, maxActive := 0, 0
214+ enter, leave := make(chan struct{}), make(chan struct{})
215+ f.onPutRepo = func() {
216+ mu.Lock()
217+ active++
218+ maxActive = max(maxActive, active)
219+ mu.Unlock()
220+ enter <- struct{}{}
221+ <-leave
222+ mu.Lock()
223+ active--
224+ mu.Unlock()
225+ }
226+ errs := make(chan error, 4)
227+ for i := range 4 {
228+ go func() {
229+ _, err := f.m.SetRepoExcluded(context.Background(), domain.Root(), f.repo, i%2 == 0)
230+ errs <- err
231+ }()
232+ }
233+ // While one writer is blocked inside the store, the others cannot have entered it.
234+ for range 4 {
235+ <-enter
236+ mu.Lock()
237+ require.Equal(t, 1, active)
238+ mu.Unlock()
239+ leave <- struct{}{}
240+ }
241+ for range 4 {
242+ require.NoError(t, <-errs)
243+ }
244+ require.Equal(t, 1, maxActive, "read-modify-write must be serialized")
245+}
246+
247+func TestMirrorsURLValidatorIsInjectable(t *testing.T) {
248+ for _, tc := range []struct {
249+ name string
250+ validate func(string) error
251+ ok bool
252+ }{
253+ {"default rejects file", nil, false},
254+ {"injected accepts file", func(string) error { return nil }, true},
255+ {"injected rejects", func(string) error { return errors.New("no") }, false},
256+ } {
257+ t.Run(tc.name, func(t *testing.T) {
258+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.ValidateURL = tc.validate })
259+ target, err := f.m.SetRepoMirror(context.Background(), domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: "file:///tmp/remotes/tools", Cron: "*/5 * * * *"})
260+ if tc.ok {
261+ require.NoError(t, err)
262+ require.Equal(t, "file:///tmp/remotes/tools", target.URL)
263+ } else {
264+ require.ErrorIs(t, err, domain.ErrInvalid)
265+ }
266+ })
267+ }
268+}
269+
270+func TestMirrorsNextRunIsComputedForStatusOnly(t *testing.T) {
271+ f := newMirrorFixture(t)
272+ view, err := f.m.GetStatus(context.Background(), domain.Root(), f.repo)
273+ require.NoError(t, err)
274+ require.True(t, view.NextRunAt.Equal(now.Add(8*time.Minute)) || !view.NextRunAt.IsZero())
275+ require.Equal(t, 0, view.NextRunAt.Minute()%5)
276+}
A internal/usecase/mirrors_fixture_test.go +266 -0
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+ "bnema.dev/ermite/internal/usecase"
18+)
19+
20+// mirrorFixture is a Mirrors use case over in-memory state behind the mocks. Every mock
21+// answers from the fields below under mu, so tests change the world with the setters and
22+// never mutate a mock after construction.
23+type mirrorFixture struct {
24+ m *usecase.Mirrors
25+ push *mocks.MirrorPusher
26+ keys *mocks.MirrorKeyStore
27+ sealer *mocks.SecretSealer
28+ repo domain.RepoName
29+
30+ mu sync.Mutex
31+ at time.Time
32+ global domain.MirrorGlobalConfig
33+ repos []domain.Repo
34+ cfgs map[domain.RepoName]domain.MirrorRepoConfig
35+ status map[domain.RepoName]domain.MirrorStatus
36+ snaps map[domain.RepoName]domain.MirrorSnapshot
37+ refs []domain.Ref
38+ refsErr error
39+ branchErr error
40+ listRefs int
41+ statusPuts int
42+ events []domain.Event
43+ onPutRepo func() // runs inside PutRepoConfig, set before concurrency starts
44+ statusErr map[domain.RepoName]error
45+}
46+
47+func newMirrorFixture(t *testing.T) *mirrorFixture {
48+ return newMirrorFixtureWith(t, nil)
49+}
50+
51+func newMirrorFixtureWith(t *testing.T, tweak func(*usecase.MirrorsConfig)) *mirrorFixture {
52+ t.Helper()
53+ f := &mirrorFixture{
54+ push: mocks.NewMirrorPusher(t), keys: mocks.NewMirrorKeyStore(t), sealer: mocks.NewSecretSealer(t),
55+ at: now, repo: name(t, "tools"),
56+ global: domain.MirrorGlobalConfig{Enabled: true, URLTemplate: "git@example.com:acme/{repo}", Cron: "*/5 * * * *"},
57+ cfgs: map[domain.RepoName]domain.MirrorRepoConfig{}, status: map[domain.RepoName]domain.MirrorStatus{}, snaps: map[domain.RepoName]domain.MirrorSnapshot{}, statusErr: map[domain.RepoName]error{},
58+ refs: []domain.Ref{{Name: "refs/heads/main", ID: mirrorOID(t)}},
59+ }
60+ f.addRepo(f.repo)
61+ store, meta, git, reader, bus, clock := mocks.NewMirrorStore(t), mocks.NewRepoMetaStore(t), mocks.NewGitRepos(t), mocks.NewRepoReader(t), mocks.NewEventBus(t), mocks.NewClock(t)
62+ clock.EXPECT().Now().RunAndReturn(func() time.Time { f.mu.Lock(); defer f.mu.Unlock(); return f.at }).Maybe()
63+ meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
64+ f.mu.Lock()
65+ defer f.mu.Unlock()
66+ for _, r := range f.repos {
67+ if r.Name == n {
68+ return r, nil
69+ }
70+ }
71+ return domain.Repo{}, domain.ErrNotFound
72+ }).Maybe()
73+ meta.EXPECT().List(mock.Anything).RunAndReturn(func(context.Context) ([]domain.Repo, error) {
74+ f.mu.Lock()
75+ defer f.mu.Unlock()
76+ return append([]domain.Repo(nil), f.repos...), nil
77+ }).Maybe()
78+ store.EXPECT().GetGlobal(mock.Anything).RunAndReturn(func(context.Context) (domain.MirrorGlobalConfig, error) {
79+ f.mu.Lock()
80+ defer f.mu.Unlock()
81+ return f.global, nil
82+ }).Maybe()
83+ store.EXPECT().PutGlobal(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, g domain.MirrorGlobalConfig) error {
84+ f.mu.Lock()
85+ defer f.mu.Unlock()
86+ f.global = g
87+ return nil
88+ }).Maybe()
89+ store.EXPECT().GetRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorRepoConfig, error) {
90+ f.mu.Lock()
91+ defer f.mu.Unlock()
92+ if c, ok := f.cfgs[n]; ok {
93+ return c, nil
94+ }
95+ return domain.MirrorRepoConfig{}, domain.ErrNotFound
96+ }).Maybe()
97+ store.EXPECT().PutRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, c domain.MirrorRepoConfig) error {
98+ if f.onPutRepo != nil {
99+ f.onPutRepo()
100+ }
101+ f.mu.Lock()
102+ defer f.mu.Unlock()
103+ f.cfgs[c.Repo] = c
104+ return nil
105+ }).Maybe()
106+ store.EXPECT().DeleteRepoConfig(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) error {
107+ f.mu.Lock()
108+ defer f.mu.Unlock()
109+ delete(f.cfgs, n)
110+ return nil
111+ }).Maybe()
112+ store.EXPECT().ListRepoConfigs(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorRepoConfig, error) {
113+ f.mu.Lock()
114+ defer f.mu.Unlock()
115+ var out []domain.MirrorRepoConfig
116+ for _, c := range f.cfgs {
117+ out = append(out, c)
118+ }
119+ return out, nil
120+ }).Maybe()
121+ store.EXPECT().GetStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorStatus, error) {
122+ f.mu.Lock()
123+ defer f.mu.Unlock()
124+ if s, ok := f.status[n]; ok {
125+ return s, nil
126+ }
127+ return domain.MirrorStatus{}, domain.ErrNotFound
128+ }).Maybe()
129+ store.EXPECT().PutStatus(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, s domain.MirrorStatus) error {
130+ f.mu.Lock()
131+ defer f.mu.Unlock()
132+ if err := f.statusErr[s.Repo]; err != nil {
133+ return err
134+ }
135+ f.status[s.Repo] = s
136+ f.statusPuts++
137+ return nil
138+ }).Maybe()
139+ store.EXPECT().ListStatus(mock.Anything).RunAndReturn(func(context.Context) ([]domain.MirrorStatus, error) {
140+ f.mu.Lock()
141+ defer f.mu.Unlock()
142+ var out []domain.MirrorStatus
143+ for _, s := range f.status {
144+ out = append(out, s)
145+ }
146+ return out, nil
147+ }).Maybe()
148+ store.EXPECT().GetSnapshot(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName) (domain.MirrorSnapshot, error) {
149+ f.mu.Lock()
150+ defer f.mu.Unlock()
151+ if s, ok := f.snaps[n]; ok {
152+ return s, nil
153+ }
154+ return domain.MirrorSnapshot{}, domain.ErrNotFound
155+ }).Maybe()
156+ store.EXPECT().PutSnapshot(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, n domain.RepoName, s domain.MirrorSnapshot) error {
157+ f.mu.Lock()
158+ defer f.mu.Unlock()
159+ f.snaps[n] = s
160+ return nil
161+ }).Maybe()
162+ reader.EXPECT().ListRefs(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) ([]domain.Ref, error) {
163+ f.mu.Lock()
164+ defer f.mu.Unlock()
165+ f.listRefs++
166+ return f.refs, f.refsErr
167+ }).Maybe()
168+ git.EXPECT().DefaultBranch(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.RepoName) (string, bool, error) {
169+ f.mu.Lock()
170+ defer f.mu.Unlock()
171+ return "main", true, f.branchErr
172+ }).Maybe()
173+ bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
174+ f.mu.Lock()
175+ defer f.mu.Unlock()
176+ f.events = append(f.events, ev)
177+ }).Maybe()
178+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("sealed"), now, nil).Maybe()
179+ f.sealer.EXPECT().Open([]byte("sealed"), []byte(domain.MirrorKeyPath)).Return(make([]byte, ed25519.SeedSize), nil).Maybe()
180+ cfg := usecase.MirrorsConfig{Store: store, Keys: f.keys, Sealer: f.sealer, Pusher: f.push, Meta: meta, Git: git, Reader: reader, Bus: bus, Clock: clock, ScheduleTick: time.Hour}
181+ if tweak != nil {
182+ tweak(&cfg)
183+ }
184+ f.m = usecase.NewMirrors(cfg)
185+ f.m.Start()
186+ t.Cleanup(func() {
187+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
188+ defer cancel()
189+ f.m.Close(ctx)
190+ })
191+ return f
192+}
193+
194+// addRepo declares a public repository whose mirror starts OK.
195+func (f *mirrorFixture) addRepo(n domain.RepoName) {
196+ f.mu.Lock()
197+ defer f.mu.Unlock()
198+ f.repos = append(f.repos, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now})
199+ f.status[n] = domain.MirrorStatus{Repo: n, State: domain.MirrorOK}
200+}
201+
202+func (f *mirrorFixture) advance(d time.Duration) { f.mu.Lock(); f.at = f.at.Add(d); f.mu.Unlock() }
203+func (f *mirrorFixture) setStatus(s domain.MirrorStatus) {
204+ f.mu.Lock()
205+ defer f.mu.Unlock()
206+ f.status[s.Repo] = s
207+}
208+func (f *mirrorFixture) setSnapshot(n domain.RepoName, s domain.MirrorSnapshot) {
209+ f.mu.Lock()
210+ defer f.mu.Unlock()
211+ f.snaps[n] = s
212+}
213+func (f *mirrorFixture) setRepoConfig(c domain.MirrorRepoConfig) {
214+ f.mu.Lock()
215+ defer f.mu.Unlock()
216+ f.cfgs[c.Repo] = c
217+}
218+func (f *mirrorFixture) setGlobal(g domain.MirrorGlobalConfig) {
219+ f.mu.Lock()
220+ defer f.mu.Unlock()
221+ f.global = g
222+}
223+func (f *mirrorFixture) failStatusWrites(n domain.RepoName, err error) {
224+ f.mu.Lock()
225+ defer f.mu.Unlock()
226+ f.statusErr[n] = err
227+}
228+func (f *mirrorFixture) snapshotOf(n domain.RepoName) (domain.MirrorSnapshot, bool) {
229+ f.mu.Lock()
230+ defer f.mu.Unlock()
231+ s, ok := f.snaps[n]
232+ return s, ok
233+}
234+func (f *mirrorFixture) statusOf(n domain.RepoName) domain.MirrorStatus {
235+ f.mu.Lock()
236+ defer f.mu.Unlock()
237+ return f.status[n]
238+}
239+func (f *mirrorFixture) eventList() []domain.Event {
240+ f.mu.Lock()
241+ defer f.mu.Unlock()
242+ return append([]domain.Event(nil), f.events...)
243+}
244+func (f *mirrorFixture) refReads() int { f.mu.Lock(); defer f.mu.Unlock(); return f.listRefs }
245+func (f *mirrorFixture) statusWrites() int {
246+ f.mu.Lock()
247+ defer f.mu.Unlock()
248+ return f.statusPuts
249+}
250+
251+// drain waits until no sync is queued or running: the deterministic barrier before asserting
252+// that something did not happen.
253+func (f *mirrorFixture) drain(t *testing.T) {
254+ t.Helper()
255+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
256+ defer cancel()
257+ f.m.Wait(ctx)
258+ require.NoError(t, ctx.Err(), "syncs did not finish")
259+}
260+
261+func mirrorOID(t *testing.T) domain.ObjectID {
262+ t.Helper()
263+ id, err := domain.ParseObjectID(oidA)
264+ require.NoError(t, err)
265+ return id
266+}
A internal/usecase/mirrors_key.go +131 -0
@@ -0,0 +1,131 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/ed25519"
8+ "crypto/rand"
9+ "errors"
10+ "fmt"
11+ "strings"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+const noSecretsKeyDetail = "the server has no ERMITE_SECRETS_KEY, so it cannot store the mirror key"
18+
19+// ShowKey lazily generates the server's mirror key for authenticated callers.
20+func (m *Mirrors) ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
21+ if p.IsAnonymous() {
22+ return domain.MirrorPublicKey{}, domain.ErrUnauthenticated
23+ }
24+ if m.cfg.Sealer == nil {
25+ return domain.MirrorPublicKey{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
26+ }
27+ m.keyMu.Lock()
28+ defer m.keyMu.Unlock()
29+ if err := m.loadKey(ctx, true); err != nil {
30+ return domain.MirrorPublicKey{}, err
31+ }
32+ return m.publicKeyLocked(), nil
33+}
34+
35+// RotateKey immediately replaces the mirror key and returns the replaced one (zero when there
36+// was none, or when it could not be opened). Remote authorization must be updated separately.
37+func (m *Mirrors) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
38+ if p.IsAnonymous() {
39+ return ports.MirrorKeyRotation{}, domain.ErrUnauthenticated
40+ }
41+ if !p.HasOwnerRights() {
42+ return ports.MirrorKeyRotation{}, domain.ErrForbidden
43+ }
44+ if m.cfg.Sealer == nil {
45+ return ports.MirrorKeyRotation{}, fmt.Errorf("%w: %s", domain.ErrPreconditionFailed, noSecretsKeyDetail)
46+ }
47+ m.keyMu.Lock()
48+ defer m.keyMu.Unlock()
49+ var previous domain.MirrorPublicKey
50+ // A missing key is not created just to be replaced, and a stored key that cannot be read
51+ // or opened must not prevent its replacement.
52+ if err := m.loadKey(ctx, false); err == nil {
53+ previous = m.publicKeyLocked()
54+ }
55+ if err := m.storeNewKey(ctx, false); err != nil {
56+ return ports.MirrorKeyRotation{}, err
57+ }
58+ return ports.MirrorKeyRotation{Key: m.publicKeyLocked(), Previous: previous}, nil
59+}
60+
61+func (m *Mirrors) publicKeyLocked() domain.MirrorPublicKey {
62+ host := strings.TrimPrefix(domain.MirrorKeyComment(m.cfg.PublicURL), "ermite-mirror@")
63+ return domain.MirrorPublicKeyOf(m.key.Public().(ed25519.PublicKey), host, m.keyCreated)
64+}
65+
66+// privateKey returns a copy so a transport cannot mutate the cached key. Failures are
67+// *domain.MirrorError of class internal.
68+func (m *Mirrors) privateKey(ctx context.Context) (ed25519.PrivateKey, error) {
69+ if m.cfg.Sealer == nil {
70+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: noSecretsKeyDetail}
71+ }
72+ m.keyMu.Lock()
73+ defer m.keyMu.Unlock()
74+ if err := m.loadKey(ctx, true); err != nil {
75+ detail := "cannot read the mirror key"
76+ if errors.Is(err, domain.ErrPreconditionFailed) {
77+ detail = err.Error()
78+ }
79+ return nil, &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail}
80+ }
81+ return append(ed25519.PrivateKey(nil), m.key...), nil
82+}
83+
84+// loadKey is called with keyMu held and a sealer present: it caches the stored key, creating
85+// one when none exists and create is set (ErrNotFound otherwise).
86+func (m *Mirrors) loadKey(ctx context.Context, create bool) error {
87+ if m.key != nil {
88+ return nil
89+ }
90+ sealed, created, err := m.cfg.Keys.GetMirrorKey(ctx)
91+ if err == nil {
92+ seed, err := m.cfg.Sealer.Open(sealed, []byte(domain.MirrorKeyPath))
93+ if err != nil {
94+ return fmt.Errorf("%w: cannot open the stored mirror key", domain.ErrPreconditionFailed)
95+ }
96+ if len(seed) != ed25519.SeedSize {
97+ return fmt.Errorf("%w: invalid mirror key seed", domain.ErrPreconditionFailed)
98+ }
99+ m.key, m.keyCreated = ed25519.NewKeyFromSeed(seed), created
100+ return nil
101+ }
102+ if !errors.Is(err, domain.ErrNotFound) {
103+ return fmt.Errorf("read mirror key: %w", err)
104+ }
105+ if !create {
106+ return err
107+ }
108+ err = m.storeNewKey(ctx, true)
109+ if errors.Is(err, domain.ErrAlreadyExists) {
110+ return m.loadKey(ctx, true) // another instance created it first
111+ }
112+ return err
113+}
114+
115+// storeNewKey generates, seals and persists a key, caching it only once persisted.
116+func (m *Mirrors) storeNewKey(ctx context.Context, ifAbsent bool) error {
117+ _, key, err := ed25519.GenerateKey(rand.Reader)
118+ if err != nil {
119+ return fmt.Errorf("generate mirror key: %w", err)
120+ }
121+ created := m.cfg.Clock.Now().UTC()
122+ sealed := m.cfg.Sealer.Seal(key.Seed(), []byte(domain.MirrorKeyPath))
123+ if err := m.cfg.Keys.PutMirrorKey(ctx, sealed, created, ifAbsent); err != nil {
124+ if errors.Is(err, domain.ErrAlreadyExists) {
125+ return err
126+ }
127+ return fmt.Errorf("store mirror key: %w", err)
128+ }
129+ m.key, m.keyCreated = key, created
130+ return nil
131+}
A internal/usecase/mirrors_key_fixture_test.go +52 -0
@@ -0,0 +1,52 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "errors"
7+ "sync"
8+ "testing"
9+
10+ "github.com/stretchr/testify/mock"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports/mocks"
14+ "bnema.dev/ermite/internal/usecase"
15+)
16+
17+// keyFixture is a Mirrors with only the key store, a sealing fake and a clock.
18+type keyFixture struct {
19+ m *usecase.Mirrors
20+ keys *mocks.MirrorKeyStore
21+ sealerOpenFails bool
22+ mu sync.Mutex
23+ sealed [][]byte
24+}
25+
26+func newKeyFixture(t *testing.T) *keyFixture {
27+ t.Helper()
28+ f := &keyFixture{keys: mocks.NewMirrorKeyStore(t)}
29+ sealer, clock := mocks.NewSecretSealer(t), mocks.NewClock(t)
30+ clock.EXPECT().Now().Return(now).Maybe()
31+ sealer.EXPECT().Seal(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(seed, _ []byte) []byte {
32+ f.mu.Lock()
33+ defer f.mu.Unlock()
34+ f.sealed = append(f.sealed, append([]byte(nil), seed...))
35+ return append([]byte("sealed:"), seed...)
36+ }).Maybe()
37+ sealer.EXPECT().Open(mock.Anything, []byte(domain.MirrorKeyPath)).RunAndReturn(func(b, _ []byte) ([]byte, error) {
38+ if f.sealerOpenFails {
39+ return nil, errors.New("cannot open")
40+ }
41+ return b[len("sealed:"):], nil
42+ }).Maybe()
43+ f.m = usecase.NewMirrors(usecase.MirrorsConfig{Keys: f.keys, Sealer: sealer, Clock: clock, PublicURL: "https://forge.example.com"})
44+ return f
45+}
46+
47+// seeds are the seeds generated so far.
48+func (f *keyFixture) seeds() [][]byte {
49+ f.mu.Lock()
50+ defer f.mu.Unlock()
51+ return append([][]byte(nil), f.sealed...)
52+}
A internal/usecase/mirrors_notify_test.go +112 -0
@@ -0,0 +1,112 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+ "testing/synctest"
9+ "time"
10+
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+ "bnema.dev/ermite/internal/ports/mocks"
17+)
18+
19+func TestMirrorsPushRecordNotification(t *testing.T) {
20+ f := newPushFixture(t)
21+ n := name(t, "tools")
22+ trigger := mocks.NewMirrorTrigger(t)
23+ updates := []domain.RefUpdate{}
24+ f.meta.On("Mutate", mock.Anything, n, mock.Anything).Return(domain.Repo{Name: n}, nil)
25+ f.git.On("DefaultBranch", mock.Anything, n).Return("main", true, nil)
26+ f.bus.On("Publish", mock.Anything, mock.Anything).Return()
27+ f.sched.On("Schedule", mock.Anything, n).Return()
28+ trigger.EXPECT().OnPush(mock.Anything, n, updates).Return().Once() // synchronous: verified when Record returns
29+ require.NoError(t, f.pushes.WithMirror(trigger).Record(context.Background(), domain.Root(), n, updates))
30+}
31+
32+func TestMirrorsMergeNotifiesMirror(t *testing.T) {
33+ r := newMRRig(t)
34+ r.seed()
35+ trigger := mocks.NewMirrorTrigger(t)
36+ notified := make(chan []domain.RefUpdate, 1)
37+ trigger.EXPECT().OnPush(mock.Anything, r.repo, mock.Anything).Run(func(_ context.Context, _ domain.RepoName, u []domain.RefUpdate) { notified <- u }).Return().Once()
38+ r.mrs.WithMirror(trigger)
39+ head := r.feature("f0")
40+ mr := r.open("f0")
41+ _, err := r.mrs.Merge(r.ctx, root, r.repo, mr.Number, ports.MergeParams{HeadID: head})
42+ require.NoError(t, err)
43+ r.mrs.Wait(r.ctx)
44+ select {
45+ case u := <-notified:
46+ require.Equal(t, "refs/heads/main", u[0].Ref)
47+ case <-time.After(time.Second):
48+ t.Fatal("merge did not notify mirrors")
49+ }
50+}
51+
52+func TestMirrorsDefaultBranchChangeNotifiesMirror(t *testing.T) {
53+ f := newFixture(t)
54+ ctx := context.Background()
55+ n := name(t, "tools")
56+ repo := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
57+ trigger := mocks.NewMirrorTrigger(t)
58+ trigger.EXPECT().OnPush(mock.Anything, n, mock.Anything).Return().Once()
59+ f.repos.WithMirror(trigger)
60+ f.meta.EXPECT().Get(ctx, n).Return(repo, nil)
61+ f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
62+ f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
63+ f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
64+ _, err := f.repos.Update(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
65+ require.NoError(t, err)
66+}
67+
68+func TestMirrorsExternalMutationMakesNextDueTickSync(t *testing.T) {
69+ f := newMirrorFixture(t)
70+ f.pushes(t, nil)
71+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
72+ require.NoError(t, err)
73+ f.advance(5 * time.Minute)
74+ f.m.ScheduleNow(context.Background())
75+ f.drain(t)
76+ require.Equal(t, 1, f.refReads(), "due and clean: skipped")
77+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
78+ f.advance(5 * time.Minute)
79+ f.m.ScheduleNow(context.Background())
80+ f.drain(t)
81+ require.Equal(t, 2, f.refReads(), "dirty repository synced on the next due tick")
82+}
83+
84+func TestMirrorsEventsAreDeliveredByVisibility(t *testing.T) {
85+ synctest.Test(t, func(t *testing.T) {
86+ r := newEventRig(t, 0)
87+ pub := r.repo("pub", domain.VisibilityPublic)
88+ priv := r.repo("priv", domain.VisibilityPrivate)
89+ ctx, cancel := context.WithCancel(context.Background())
90+ defer cancel()
91+ anon := r.watch(ctx, domain.Anonymous(), ports.WatchParams{})
92+ root := r.watch(ctx, domain.Root(), ports.WatchParams{})
93+ synctest.Wait()
94+ for _, n := range []domain.RepoName{priv, pub} {
95+ vis := domain.VisibilityPublic
96+ if n == priv {
97+ vis = domain.VisibilityPrivate
98+ }
99+ r.feed <- domain.MirrorFailed{Repo: n, State: domain.MirrorPaused, Class: domain.MirrorClassDiverged, At: now, Visibility: vis, CreatedAt: now}
100+ r.feed <- domain.MirrorRecovered{Repo: n, At: now, Visibility: vis, CreatedAt: now}
101+ }
102+ for _, n := range []domain.RepoName{priv, pub} {
103+ failed := root.next(t).Event.(domain.MirrorFailed)
104+ require.Equal(t, n, failed.Repo)
105+ require.Equal(t, n, root.next(t).Event.(domain.MirrorRecovered).Repo)
106+ }
107+ failed := anon.next(t).Event.(domain.MirrorFailed)
108+ require.Equal(t, pub, failed.Repo, "a private repository's mirror events stay private")
109+ require.Equal(t, pub, anon.next(t).Event.(domain.MirrorRecovered).Repo)
110+ anon.quiet(t, r, pub)
111+ })
112+}
A internal/usecase/mirrors_schedule.go +154 -0
@@ -0,0 +1,154 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "time"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+// Start starts the bounded worker pool and UTC scheduler exactly once.
15+func (m *Mirrors) Start() {
16+ m.start.Do(func() {
17+ m.mu.Lock()
18+ defer m.mu.Unlock()
19+ if m.closed {
20+ return
21+ }
22+ m.wg.Add(m.cfg.Workers + 1)
23+ for i := 0; i < m.cfg.Workers; i++ {
24+ go func() {
25+ defer m.wg.Done()
26+ for {
27+ select {
28+ case <-m.ctx.Done():
29+ return
30+ case repo := <-m.jobs:
31+ m.run(repo)
32+ }
33+ }
34+ }()
35+ }
36+ go func() {
37+ defer m.wg.Done()
38+ ticker := time.NewTicker(m.cfg.ScheduleTick)
39+ defer ticker.Stop()
40+ for {
41+ select {
42+ case <-m.ctx.Done():
43+ return
44+ case <-ticker.C:
45+ m.ScheduleNow(m.ctx)
46+ }
47+ }
48+ }()
49+ })
50+}
51+
52+// Close cancels workers and waits until they stop or the caller's deadline expires.
53+func (m *Mirrors) Close(ctx context.Context) {
54+ m.mu.Lock()
55+ m.closed = true
56+ m.cancel()
57+ m.mu.Unlock()
58+ done := make(chan struct{})
59+ go func() { m.wg.Wait(); close(done) }()
60+ select {
61+ case <-ctx.Done():
62+ case <-done:
63+ }
64+}
65+
66+// OnPush marks the repository dirty without I/O or waiting for a worker.
67+func (m *Mirrors) OnPush(_ context.Context, repo domain.RepoName, _ []domain.RefUpdate) {
68+ m.mu.Lock()
69+ defer m.mu.Unlock()
70+ if !m.closed {
71+ g := m.gens[repo]
72+ g.dirty++
73+ m.gens[repo] = g
74+ }
75+}
76+
77+// ScheduleNow examines the current minute; missed minutes are not replayed. A repository that
78+// was due but found the queue full is kept and retried on the next calls, so a full queue
79+// delays a sync instead of skipping a period. Workers must be running (Start).
80+func (m *Mirrors) ScheduleNow(ctx context.Context) {
81+ m.scheduleMu.Lock()
82+ defer m.scheduleMu.Unlock()
83+ now := m.cfg.Clock.Now().UTC()
84+ minute := now.Truncate(time.Minute)
85+ newMinute := minute.After(m.lastMinute)
86+ if !newMinute && len(m.deferred) == 0 {
87+ return
88+ }
89+ log := zerowrap.FromCtx(ctx)
90+ repos, err := m.cfg.Meta.List(ctx)
91+ if err != nil {
92+ log.Warn().Err(err).Msg("mirror schedule: cannot list repositories")
93+ return
94+ }
95+ if newMinute {
96+ m.lastMinute = minute
97+ }
98+ live := make(map[domain.RepoName]bool, len(repos))
99+ for _, r := range repos {
100+ if ctx.Err() != nil {
101+ return
102+ }
103+ live[r.Name] = true
104+ _, wasDeferred := m.deferred[r.Name]
105+ if !newMinute && !wasDeferred {
106+ continue
107+ }
108+ delete(m.deferred, r.Name)
109+ t, err := m.target(ctx, r.Name)
110+ if err != nil || !t.Enabled {
111+ continue
112+ }
113+ s, err := m.status(ctx, r.Name)
114+ if err != nil || s.State == domain.MirrorPaused {
115+ continue
116+ }
117+ due := wasDeferred
118+ if !due {
119+ if s.State == domain.MirrorFailing {
120+ due = !now.Before(s.NextRetryAt)
121+ } else {
122+ due = (domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}).Due(minute)
123+ }
124+ }
125+ if !due {
126+ continue
127+ }
128+ m.mu.Lock()
129+ g := m.gens[r.Name]
130+ m.mu.Unlock()
131+ if s.State == domain.MirrorOK && g.cleanKnown && g.clean == g.dirty {
132+ snapshot, e := m.cfg.Store.GetSnapshot(ctx, r.Name)
133+ if e == nil && snapshot.URL == t.URL && snapshot.AllBranches == t.AllBranches {
134+ continue
135+ }
136+ }
137+ if _, _, err := m.enqueue(r.Name, mirrorReq{}); errors.Is(err, domain.ErrResourceExhausted) {
138+ m.deferred[r.Name] = struct{}{}
139+ log.Warn().Str("repo", r.Name.String()).Msg("mirror queue full: sync deferred")
140+ }
141+ }
142+ for repo := range m.deferred {
143+ if !live[repo] {
144+ delete(m.deferred, repo)
145+ }
146+ }
147+ m.mu.Lock()
148+ for repo := range m.gens {
149+ if !live[repo] {
150+ delete(m.gens, repo)
151+ }
152+ }
153+ m.mu.Unlock()
154+}
A internal/usecase/mirrors_sync.go +299 -0
@@ -0,0 +1,299 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// mirrorReq is what a sync request asks for. Requests that coalesce merge by OR.
16+type mirrorReq struct{ force, manual bool }
17+
18+func (r mirrorReq) merge(o mirrorReq) mirrorReq {
19+ return mirrorReq{force: r.force || o.force, manual: r.manual || o.manual}
20+}
21+
22+// mirrorFlight is the single in-flight sync of a repository: cur is running, next is the
23+// merged request that arrived meanwhile (one rerun, whatever the number of requests).
24+type mirrorFlight struct {
25+ cur mirrorReq
26+ next *mirrorReq
27+ done chan struct{} // closed with the result of cur's first run
28+ completed bool
29+ result ports.MirrorSyncResult
30+ err error
31+}
32+
33+var (
34+ _ ports.MirrorService = (*Mirrors)(nil)
35+ _ ports.MirrorTrigger = (*Mirrors)(nil)
36+ _ ports.MirrorWarner = (*Mirrors)(nil)
37+)
38+
39+// Sync waits for its own run; overlapping requests coalesce into one sticky rerun.
40+func (m *Mirrors) Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (ports.MirrorSyncResult, error) {
41+ if err := m.admin(ctx, p, repo); err != nil {
42+ return ports.MirrorSyncResult{}, err
43+ }
44+ m.Start()
45+ f, queued, err := m.enqueue(repo, mirrorReq{force: force, manual: true})
46+ if err != nil {
47+ return ports.MirrorSyncResult{}, err
48+ }
49+ if queued {
50+ return ports.MirrorSyncResult{Repo: repo, Status: domain.MirrorStatus{Repo: repo}, Queued: true}, nil
51+ }
52+ select {
53+ case <-m.ctx.Done():
54+ return ports.MirrorSyncResult{}, m.ctx.Err()
55+ case <-ctx.Done():
56+ return ports.MirrorSyncResult{}, ctx.Err()
57+ case <-f.done:
58+ f.result.Repo = repo
59+ return f.result, f.err
60+ }
61+}
62+
63+// SyncAll syncs every repository whose mirror is enabled, as an owner. A failing repository
64+// does not stop the others: its outcome carries the error. The returned error is only for
65+// what prevents the whole request (authorization, listing, cancellation).
66+func (m *Mirrors) SyncAll(ctx context.Context, p domain.Principal, force bool) ([]ports.MirrorSyncResult, error) {
67+ if err := mirrorOwner(p); err != nil {
68+ return nil, err
69+ }
70+ repos, err := m.cfg.Meta.List(ctx)
71+ if err != nil {
72+ return nil, err
73+ }
74+ out := make([]ports.MirrorSyncResult, 0, len(repos))
75+ for _, r := range repos {
76+ if err := ctx.Err(); err != nil {
77+ return out, err
78+ }
79+ t, err := m.target(ctx, r.Name)
80+ if err == nil && !t.Enabled {
81+ continue
82+ }
83+ var res ports.MirrorSyncResult
84+ if err == nil {
85+ res, err = m.Sync(ctx, p, r.Name, force)
86+ }
87+ if ctxErr := ctx.Err(); ctxErr != nil {
88+ return out, ctxErr
89+ }
90+ res.Repo = r.Name
91+ if res.Status.Repo == (domain.RepoName{}) {
92+ res.Status.Repo = r.Name
93+ }
94+ res.Err = err
95+ out = append(out, res)
96+ }
97+ return out, nil
98+}
99+
100+// Wait blocks until no sync is queued or running, or ctx is done. It is a test barrier: call it
101+// only while nothing enqueues new syncs and before Close (queued jobs are not drained by Close).
102+func (m *Mirrors) Wait(ctx context.Context) {
103+ done := make(chan struct{})
104+ go func() { m.flightWG.Wait(); close(done) }()
105+ select {
106+ case <-done:
107+ case <-ctx.Done():
108+ }
109+}
110+
111+func (m *Mirrors) enqueue(repo domain.RepoName, req mirrorReq) (*mirrorFlight, bool, error) {
112+ m.mu.Lock()
113+ defer m.mu.Unlock()
114+ if m.closed {
115+ return nil, false, context.Canceled
116+ }
117+ if f := m.flights[repo]; f != nil {
118+ next := req
119+ if f.next != nil {
120+ next = f.next.merge(req)
121+ }
122+ f.next = &next
123+ return f, true, nil
124+ }
125+ f := &mirrorFlight{cur: req, done: make(chan struct{})}
126+ select {
127+ case m.jobs <- repo:
128+ m.flights[repo] = f
129+ m.flightWG.Add(1)
130+ return f, false, nil
131+ default:
132+ return nil, false, domain.ErrResourceExhausted
133+ }
134+}
135+
136+func (m *Mirrors) run(repo domain.RepoName) {
137+ m.mu.Lock()
138+ f := m.flights[repo]
139+ m.mu.Unlock()
140+ for {
141+ result, err := m.attempt(m.ctx, repo, f.cur)
142+ m.mu.Lock()
143+ if !f.completed {
144+ f.result, f.err = result, err
145+ f.completed = true
146+ close(f.done)
147+ }
148+ if f.next == nil || m.closed {
149+ delete(m.flights, repo)
150+ m.flightWG.Done()
151+ m.mu.Unlock()
152+ return
153+ }
154+ f.cur, f.next = *f.next, nil
155+ m.mu.Unlock()
156+ }
157+}
158+
159+// mirrorPlan is what attempt decided under the configuration lock, before pushing.
160+type mirrorPlan struct {
161+ meta domain.Repo
162+ target domain.MirrorTarget
163+ prev domain.MirrorStatus
164+ start time.Time
165+ generation uint64
166+}
167+
168+// plan reads the target and status and applies the skip and throttle rules. When done is set
169+// the attempt ends with result and err.
170+func (m *Mirrors) plan(ctx context.Context, repo domain.RepoName, req mirrorReq) (pl mirrorPlan, result ports.MirrorSyncResult, done bool, err error) {
171+ m.configMu.RLock()
172+ defer m.configMu.RUnlock()
173+ meta, err := m.cfg.Meta.Get(ctx, repo)
174+ if err != nil {
175+ return pl, result, true, err
176+ }
177+ t, err := m.target(ctx, repo)
178+ if err != nil {
179+ return pl, result, true, err
180+ }
181+ prev, err := m.status(ctx, repo)
182+ if err != nil {
183+ return pl, result, true, err
184+ }
185+ now := m.cfg.Clock.Now().UTC()
186+ if !t.Enabled {
187+ s := domain.MirrorDisable(prev, now)
188+ if prev.State != domain.MirrorDisabled {
189+ err = m.cfg.Store.PutStatus(ctx, s)
190+ }
191+ return pl, ports.MirrorSyncResult{Status: s}, true, err
192+ }
193+ if !req.force && (prev.State == domain.MirrorPaused || (!req.manual && prev.State == domain.MirrorFailing && now.Before(prev.NextRetryAt))) {
194+ return pl, ports.MirrorSyncResult{Status: prev}, true, nil
195+ }
196+ if req.manual && !req.force && !prev.LastAttemptAt.IsZero() && now.Sub(prev.LastAttemptAt) < domain.MirrorManualInterval {
197+ return pl, ports.MirrorSyncResult{Status: prev}, true, fmt.Errorf("%w: last sync %s ago; wait %s or use --force", domain.ErrResourceExhausted, now.Sub(prev.LastAttemptAt).Round(time.Second), domain.MirrorManualInterval)
198+ }
199+ m.mu.Lock()
200+ generation := m.gens[repo].dirty
201+ m.mu.Unlock()
202+ return mirrorPlan{meta: meta, target: t, prev: prev, start: now, generation: generation}, result, false, nil
203+}
204+
205+// attempt runs one sync. The configuration lock is held to read and to record, never while
206+// pushing: a slow remote must not block configuration changes.
207+func (m *Mirrors) attempt(ctx context.Context, repo domain.RepoName, req mirrorReq) (ports.MirrorSyncResult, error) {
208+ pl, result, done, err := m.plan(ctx, repo, req)
209+ if done || err != nil {
210+ return result, err
211+ }
212+ t := pl.target
213+ // A missing sealer or an unreadable key fails the attempt before any repository I/O.
214+ key, runErr := m.privateKey(ctx)
215+ var refs []domain.Ref
216+ var selected []domain.MirrorRef
217+ if runErr == nil {
218+ refs, runErr = m.cfg.Reader.ListRefs(ctx, repo)
219+ }
220+ if runErr == nil {
221+ var branch string
222+ branch, _, runErr = m.cfg.Git.DefaultBranch(ctx, repo)
223+ if runErr == nil {
224+ selected = domain.MirrorRefspecs(refs, branch, t.AllBranches)
225+ }
226+ }
227+ noOp := false
228+ if runErr == nil && !req.force {
229+ snapshot, e := m.cfg.Store.GetSnapshot(ctx, repo)
230+ if e != nil && !errors.Is(e, domain.ErrNotFound) {
231+ return ports.MirrorSyncResult{}, e
232+ }
233+ noOp = e == nil && snapshot.Equal(t.URL, selected, t.AllBranches)
234+ }
235+ if runErr == nil && !noOp {
236+ specs := make([]string, len(selected))
237+ for i, ref := range selected {
238+ specs[i] = ref.Name + ":" + ref.Name
239+ }
240+ // An empty ref list must not invoke git's implicit default push behavior.
241+ if len(specs) > 0 {
242+ _, runErr = m.cfg.Pusher.Push(ctx, ports.MirrorPush{Repo: repo, URL: t.URL, Refspecs: specs, PrivateKey: key})
243+ }
244+ }
245+ if ctx.Err() != nil {
246+ return ports.MirrorSyncResult{}, ctx.Err()
247+ }
248+ var failure *domain.MirrorError
249+ if runErr != nil && !errors.As(runErr, &failure) {
250+ failure = &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: "local mirror operation failed"}
251+ runErr = failure
252+ }
253+ return m.record(ctx, repo, pl, selected, noOp, failure, runErr)
254+}
255+
256+// record stores the outcome, unless the effective target changed during the push: the result
257+// then describes a target that no longer applies and is discarded. The comparison includes the
258+// cron on purpose, like resetIfChanged: a schedule change during a push costs one extra push.
259+func (m *Mirrors) record(ctx context.Context, repo domain.RepoName, pl mirrorPlan, selected []domain.MirrorRef, noOp bool, failure *domain.MirrorError, runErr error) (ports.MirrorSyncResult, error) {
260+ m.configMu.RLock()
261+ defer m.configMu.RUnlock()
262+ t, err := m.target(ctx, repo)
263+ if err != nil {
264+ return ports.MirrorSyncResult{}, err
265+ }
266+ if !sameMirrorTarget(pl.target, t) {
267+ s, err := m.status(ctx, repo)
268+ return ports.MirrorSyncResult{Status: s}, err
269+ }
270+ // The push may be slow: completion, retry and success times come from the
271+ // clock after it returns; only LastAttemptAt marks when the attempt began.
272+ now := m.cfg.Clock.Now().UTC()
273+ transition := domain.ApplyMirrorOutcome(pl.prev, domain.MirrorOutcome{Err: failure, NoOp: noOp}, now)
274+ transition.Status.LastAttemptAt = pl.start
275+ if failure == nil {
276+ if err := m.cfg.Store.PutSnapshot(ctx, repo, domain.MirrorSnapshot{URL: t.URL, Refs: selected, AllBranches: t.AllBranches, At: now}); err != nil {
277+ return ports.MirrorSyncResult{}, err
278+ }
279+ }
280+ if err := m.cfg.Store.PutStatus(ctx, transition.Status); err != nil {
281+ return ports.MirrorSyncResult{}, err
282+ }
283+ if failure == nil {
284+ m.mu.Lock()
285+ g := m.gens[repo]
286+ g.clean, g.cleanKnown = pl.generation, true
287+ m.gens[repo] = g
288+ m.mu.Unlock()
289+ }
290+ if m.cfg.Bus != nil {
291+ if transition.FailedEvent {
292+ m.cfg.Bus.Publish(ctx, domain.MirrorFailed{Repo: repo, State: transition.Status.State, Class: transition.Status.Class, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
293+ }
294+ if transition.Recovered {
295+ m.cfg.Bus.Publish(ctx, domain.MirrorRecovered{Repo: repo, At: now, Visibility: pl.meta.Visibility, CreatedAt: pl.meta.CreatedAt})
296+ }
297+ }
298+ return ports.MirrorSyncResult{Status: transition.Status, NoOp: noOp}, runErr
299+}
A internal/usecase/mirrors_sync_test.go +492 -0
@@ -0,0 +1,492 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/zerowrap"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+var errBoom = errors.New("boom")
24+
25+func (f *mirrorFixture) pushes(t *testing.T, result error) *int {
26+ t.Helper()
27+ var mu sync.Mutex
28+ n := new(int)
29+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
30+ mu.Lock()
31+ defer mu.Unlock()
32+ *n++
33+ return ports.MirrorPushResult{}, result
34+ }).Maybe()
35+ return n
36+}
37+
38+func TestMirrorsNoOpThrottleForce(t *testing.T) {
39+ f := newMirrorFixture(t)
40+ f.setSnapshot(f.repo, domain.MirrorSnapshot{URL: "git@example.com:acme/tools", Refs: []domain.MirrorRef{{Name: "refs/heads/main", ID: mirrorOID(t)}}})
41+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
42+ require.NoError(t, err)
43+ require.True(t, v.NoOp)
44+ _, err = f.m.Sync(context.Background(), domain.Root(), f.repo, false)
45+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
46+ require.Contains(t, err.Error(), "wait 1m0s", "the interval comes from the domain constant")
47+ require.Contains(t, err.Error(), "last sync 0s ago")
48+ f.push.EXPECT().Push(mock.Anything, mock.MatchedBy(func(p ports.MirrorPush) bool {
49+ return len(p.Refspecs) == 1 && p.Refspecs[0] == "refs/heads/main:refs/heads/main"
50+ })).Return(ports.MirrorPushResult{}, nil).Once()
51+ v, err = f.m.Sync(context.Background(), domain.Root(), f.repo, true)
52+ require.NoError(t, err)
53+ require.False(t, v.NoOp)
54+}
55+
56+func TestMirrorsIncidentAndRecovery(t *testing.T) {
57+ f := newMirrorFixture(t)
58+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Times(5)
59+ for i := 1; i <= 5; i++ {
60+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
61+ require.Error(t, err)
62+ require.Equal(t, i, v.Status.ConsecutiveFailures)
63+ if i < 5 {
64+ require.Equal(t, domain.MirrorFailing, v.Status.State)
65+ require.Equal(t, domain.MirrorBackoff(i), v.Status.NextRetryAt.Sub(v.Status.LastAttemptAt))
66+ } else {
67+ require.Equal(t, domain.MirrorPaused, v.Status.State)
68+ }
69+ }
70+ events := f.eventList()
71+ require.Len(t, events, 1, "mirror.failed once per incident")
72+ failed, ok := events[0].(domain.MirrorFailed)
73+ require.True(t, ok)
74+ require.Equal(t, domain.MirrorFailing, failed.State)
75+ // A paused mirror is not retried by the schedule.
76+ f.advance(24 * time.Hour)
77+ f.m.ScheduleNow(context.Background())
78+ f.drain(t)
79+ f.push.AssertNumberOfCalls(t, "Push", 5)
80+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, nil).Once()
81+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
82+ require.NoError(t, err)
83+ require.Equal(t, domain.MirrorOK, v.Status.State)
84+ events = f.eventList()
85+ require.Len(t, events, 2)
86+ require.IsType(t, domain.MirrorRecovered{}, events[1])
87+}
88+
89+func TestMirrorsSingleFlight(t *testing.T) {
90+ f := newMirrorFixture(t)
91+ entered, release := make(chan struct{}), make(chan struct{})
92+ var mu sync.Mutex
93+ calls := 0
94+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
95+ mu.Lock()
96+ calls++
97+ first := calls == 1
98+ mu.Unlock()
99+ if first {
100+ close(entered)
101+ <-release
102+ }
103+ return ports.MirrorPushResult{}, nil
104+ })
105+ done := make(chan error, 1)
106+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
107+ <-entered
108+ type outcome struct {
109+ res ports.MirrorSyncResult
110+ err error
111+ }
112+ results := make(chan outcome, 20)
113+ var wg sync.WaitGroup
114+ for range 20 {
115+ wg.Add(1)
116+ go func() {
117+ defer wg.Done()
118+ res, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
119+ results <- outcome{res, err}
120+ }()
121+ }
122+ wg.Wait()
123+ close(results)
124+ for o := range results {
125+ require.NoError(t, o.err)
126+ require.True(t, o.res.Queued)
127+ require.Equal(t, f.repo, o.res.Repo)
128+ }
129+ close(release)
130+ require.NoError(t, <-done)
131+ f.drain(t)
132+ mu.Lock()
133+ defer mu.Unlock()
134+ require.Equal(t, 2, calls, "twenty overlapping requests coalesce into one rerun")
135+}
136+
137+func TestMirrorsCoalescedManualAppliesThrottle(t *testing.T) {
138+ f := newMirrorFixture(t)
139+ entered, release := make(chan struct{}), make(chan struct{})
140+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
141+ close(entered)
142+ <-release
143+ return ports.MirrorPushResult{}, nil
144+ }).Once()
145+ done := make(chan error, 1)
146+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
147+ <-entered
148+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
149+ require.NoError(t, err)
150+ require.True(t, v.Queued)
151+ close(release)
152+ require.NoError(t, <-done)
153+ f.drain(t)
154+ // The rerun is manual and not forced: the throttle (just attempted) stops it, so no second push
155+ // (a second call would fail the Once expectation).
156+ f.push.AssertNumberOfCalls(t, "Push", 1)
157+}
158+
159+func TestMirrorsDivergedPausedAtOnce(t *testing.T) {
160+ f := newMirrorFixture(t)
161+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged}).Once()
162+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
163+ require.Error(t, err)
164+ require.Equal(t, domain.MirrorPaused, v.Status.State)
165+ for range 3 {
166+ f.advance(time.Hour)
167+ f.m.ScheduleNow(context.Background())
168+ }
169+ f.drain(t)
170+ f.push.AssertNumberOfCalls(t, "Push", 1)
171+ require.Len(t, f.eventList(), 1)
172+}
173+
174+func TestMirrorsCloseCancelsWorker(t *testing.T) {
175+ f := newMirrorFixture(t)
176+ entered, exited := make(chan struct{}), make(chan struct{})
177+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.MirrorPush) (ports.MirrorPushResult, error) {
178+ close(entered)
179+ <-ctx.Done()
180+ close(exited)
181+ return ports.MirrorPushResult{}, context.Canceled
182+ }).Once()
183+ done := make(chan error, 1)
184+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
185+ <-entered
186+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
187+ defer cancel()
188+ f.m.Close(ctx)
189+ require.NoError(t, ctx.Err())
190+ select {
191+ case <-exited:
192+ default:
193+ t.Fatal("Close returned before worker exited")
194+ }
195+ require.ErrorIs(t, <-done, context.Canceled)
196+ f.m.Close(ctx) // idempotent; Start cannot resurrect workers.
197+ f.m.Start()
198+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
199+ require.ErrorIs(t, err, context.Canceled)
200+}
201+
202+func TestMirrorsCronBackoff(t *testing.T) {
203+ f := newMirrorFixture(t)
204+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}).Twice()
205+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, false)
206+ require.Error(t, err)
207+ f.advance(time.Minute)
208+ f.m.ScheduleNow(context.Background())
209+ f.drain(t)
210+ f.push.AssertNumberOfCalls(t, "Push", 1)
211+ f.advance(4 * time.Minute)
212+ f.m.ScheduleNow(context.Background())
213+ f.drain(t)
214+ f.push.AssertNumberOfCalls(t, "Push", 2)
215+}
216+
217+func TestMirrorsCronDueAndDirtySkip(t *testing.T) {
218+ f := newMirrorFixture(t)
219+ f.pushes(t, nil)
220+ _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
221+ require.NoError(t, err)
222+ require.Equal(t, 1, f.refReads())
223+ f.advance(5 * time.Minute)
224+ f.m.ScheduleNow(context.Background()) // due and clean: skipped without reading refs
225+ f.drain(t)
226+ require.Equal(t, 1, f.refReads())
227+ // Merges and default branch changes both end in OnPush: an idempotent dirty mark.
228+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "refs/heads/main"}})
229+ f.m.OnPush(context.Background(), f.repo, []domain.RefUpdate{{Ref: "HEAD"}})
230+ f.m.ScheduleNow(context.Background()) // same minute: nothing runs, although dirty
231+ f.drain(t)
232+ require.Equal(t, 1, f.refReads())
233+ f.advance(5 * time.Minute)
234+ f.m.ScheduleNow(context.Background())
235+ f.drain(t)
236+ require.Equal(t, 2, f.refReads(), "the dirty repository is examined on the next due tick")
237+}
238+
239+func TestMirrorsScheduleKeepsFullQueueRepositoriesForNextTicks(t *testing.T) {
240+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
241+ repos := []domain.RepoName{f.repo, name(t, "b"), name(t, "c"), name(t, "d")}
242+ for _, r := range repos[1:] {
243+ f.addRepo(r)
244+ }
245+ gate := make(chan struct{})
246+ var mu sync.Mutex
247+ pushed := map[string]int{}
248+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
249+ <-gate
250+ mu.Lock()
251+ pushed[p.URL]++
252+ mu.Unlock()
253+ return ports.MirrorPushResult{}, nil
254+ })
255+ f.advance(5 * time.Minute)
256+ f.m.ScheduleNow(context.Background())
257+ // The worker holds one repository and the queue one: the other two found it full.
258+ close(gate)
259+ for range 6 { // later ticks of the same minute drain the deferred set
260+ f.drain(t)
261+ f.m.ScheduleNow(context.Background())
262+ }
263+ f.drain(t)
264+ mu.Lock()
265+ defer mu.Unlock()
266+ for _, r := range repos {
267+ require.Equal(t, 1, pushed["git@example.com:acme/"+r.String()], r.String())
268+ }
269+}
270+
271+func TestMirrorsSyncRefusedWhenQueueFull(t *testing.T) {
272+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Workers, c.QueueSize = 1, 1 })
273+ second, third := name(t, "b"), name(t, "c")
274+ f.addRepo(second)
275+ f.addRepo(third)
276+ gate := make(chan struct{})
277+ entered := make(chan struct{}, 8)
278+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
279+ entered <- struct{}{}
280+ <-gate
281+ return ports.MirrorPushResult{}, nil
282+ })
283+ first := make(chan error, 1)
284+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); first <- err }()
285+ <-entered // the single worker is busy
286+ // Enqueueing is synchronous: this request takes the only queue slot, then waits and gives up.
287+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
288+ defer cancel()
289+ _, err := f.m.Sync(ctx, domain.Root(), second, true)
290+ require.ErrorIs(t, err, context.DeadlineExceeded)
291+ _, err = f.m.Sync(context.Background(), domain.Root(), third, true)
292+ require.ErrorIs(t, err, domain.ErrResourceExhausted)
293+ close(gate)
294+ require.NoError(t, <-first)
295+ f.drain(t)
296+}
297+
298+func TestMirrorsSlowPushTimestamps(t *testing.T) {
299+ f := newMirrorFixture(t)
300+ start := f.at
301+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
302+ f.advance(10 * time.Minute)
303+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassTransient}
304+ }).Once()
305+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
306+ require.Error(t, err)
307+ require.True(t, v.Status.LastAttemptAt.Equal(start))
308+ require.True(t, v.Status.NextRetryAt.Equal(start.Add(10*time.Minute+domain.MirrorBackoff(1))))
309+}
310+
311+func TestMirrorsPushDoesNotHoldConfigLock(t *testing.T) {
312+ f := newMirrorFixture(t)
313+ entered, release := make(chan struct{}), make(chan struct{})
314+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
315+ close(entered)
316+ <-release
317+ return ports.MirrorPushResult{}, nil
318+ }).Once()
319+ done := make(chan error, 1)
320+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
321+ <-entered
322+ changed := make(chan error, 1)
323+ go func() {
324+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{Cron: ptr("*/10 * * * *")})
325+ changed <- err
326+ }()
327+ select {
328+ case err := <-changed:
329+ require.NoError(t, err)
330+ case <-time.After(5 * time.Second): // only on failure: the push holds the configuration lock
331+ t.Fatal("configuration change blocked by a running push")
332+ }
333+ close(release)
334+ require.NoError(t, <-done)
335+}
336+
337+func TestMirrorsResultDiscardedWhenTargetChangedDuringPush(t *testing.T) {
338+ f := newMirrorFixture(t)
339+ f.setStatus(domain.MirrorStatus{Repo: f.repo, State: domain.MirrorFailing, Class: domain.MirrorClassTransient, ConsecutiveFailures: 1})
340+ entered, release := make(chan struct{}), make(chan struct{})
341+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, ports.MirrorPush) (ports.MirrorPushResult, error) {
342+ close(entered)
343+ <-release
344+ return ports.MirrorPushResult{}, nil
345+ }).Once()
346+ done := make(chan error, 1)
347+ go func() { _, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true); done <- err }()
348+ <-entered
349+ _, err := f.m.SetGlobalConfig(context.Background(), domain.Root(), ports.MirrorGlobalPatch{URLTemplate: ptr("git@other.example:acme/{repo}")})
350+ require.NoError(t, err)
351+ close(release)
352+ require.NoError(t, <-done)
353+ _, ok := f.snapshotOf(f.repo)
354+ require.False(t, ok, "no snapshot for a target that no longer applies")
355+ require.Empty(t, f.eventList(), "no mirror.recovered for the old target")
356+ s := f.statusOf(f.repo)
357+ require.Equal(t, domain.MirrorOK, s.State)
358+ require.True(t, s.LastSuccessAt.IsZero(), "the push result was not recorded")
359+}
360+
361+func TestMirrorsSyncAllContinuesPastFailures(t *testing.T) {
362+ f := newMirrorFixture(t)
363+ bad, off := name(t, "bad"), name(t, "off")
364+ f.addRepo(bad)
365+ f.addRepo(off)
366+ f.setRepoConfig(domain.MirrorRepoConfig{Repo: off, Excluded: true})
367+ f.push.EXPECT().Push(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, p ports.MirrorPush) (ports.MirrorPushResult, error) {
368+ if strings.HasSuffix(p.URL, "/bad") {
369+ return ports.MirrorPushResult{}, &domain.MirrorError{Class: domain.MirrorClassDiverged, Detail: "rejected"}
370+ }
371+ return ports.MirrorPushResult{}, nil
372+ })
373+ got, err := f.m.SyncAll(context.Background(), domain.Root(), true)
374+ require.NoError(t, err)
375+ require.Len(t, got, 2, "the excluded repository is skipped")
376+ byRepo := map[domain.RepoName]ports.MirrorSyncResult{}
377+ for _, r := range got {
378+ byRepo[r.Repo] = r
379+ }
380+ require.NoError(t, byRepo[f.repo].Err)
381+ require.Equal(t, domain.MirrorOK, byRepo[f.repo].Status.State)
382+ var me *domain.MirrorError
383+ require.ErrorAs(t, byRepo[bad].Err, &me)
384+ require.Equal(t, domain.MirrorPaused, byRepo[bad].Status.State)
385+ _, listed := byRepo[off]
386+ require.False(t, listed)
387+ _, err = f.m.SyncAll(context.Background(), tokenPrincipal(domain.ScopeRepoAdmin), true)
388+ require.ErrorIs(t, err, domain.ErrForbidden)
389+}
390+
391+func TestMirrorsLocalFailuresAreInternalAndNeverPush(t *testing.T) {
392+ for _, tc := range []struct {
393+ name string
394+ set func(*mirrorFixture)
395+ }{
396+ {"ListRefs", func(f *mirrorFixture) { f.mu.Lock(); f.refsErr = errBoom; f.mu.Unlock() }},
397+ {"DefaultBranch", func(f *mirrorFixture) { f.mu.Lock(); f.branchErr = errBoom; f.mu.Unlock() }},
398+ } {
399+ t.Run(tc.name, func(t *testing.T) {
400+ f := newMirrorFixture(t)
401+ tc.set(f)
402+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true) // a Push call would fail the strict mock
403+ var me *domain.MirrorError
404+ require.ErrorAs(t, err, &me)
405+ require.Equal(t, domain.MirrorClassInternal, me.Class)
406+ require.NotContains(t, me.Detail, "boom")
407+ require.Equal(t, domain.MirrorFailing, v.Status.State)
408+ })
409+ }
410+}
411+
412+func TestMirrorsMissingSecretsKeyFailsBeforeReadingRefs(t *testing.T) {
413+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
414+ v, err := f.m.Sync(context.Background(), domain.Root(), f.repo, true)
415+ var me *domain.MirrorError
416+ require.ErrorAs(t, err, &me)
417+ require.Equal(t, domain.MirrorClassInternal, me.Class)
418+ require.Contains(t, me.Detail, "ERMITE_SECRETS_KEY")
419+ require.Equal(t, domain.MirrorFailing, v.Status.State)
420+ require.Zero(t, f.refReads())
421+}
422+
423+func TestMirrorsErrorsAndLogsDoNotExposeSecrets(t *testing.T) {
424+ f := newMirrorFixture(t)
425+ var logs bytes.Buffer
426+ ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &logs}))
427+ credentialURL := "ssh://alice:secret-password@example.com/acme/tools"
428+ _, err := f.m.SetRepoMirror(ctx, domain.Root(), domain.MirrorRepoConfig{Repo: f.repo, URL: credentialURL})
429+ require.Error(t, err)
430+ require.NotContains(t, err.Error(), "secret-password")
431+ require.NotContains(t, err.Error(), "alice")
432+ f.push.EXPECT().Push(mock.Anything, mock.Anything).Return(ports.MirrorPushResult{}, errors.New("local key PRIVATE-KEY-MARKER ssh://alice:secret-password@example.com/acme/tools")).Once()
433+ _, err = f.m.Sync(ctx, domain.Root(), f.repo, true)
434+ require.Error(t, err)
435+ require.NotContains(t, err.Error(), "PRIVATE-KEY-MARKER")
436+ require.NotContains(t, err.Error(), "secret-password")
437+ require.NotContains(t, logs.String(), "PRIVATE-KEY-MARKER")
438+ require.NotContains(t, logs.String(), "secret-password")
439+}
440+
441+func TestMirrorsKeyLazyGenerationAndRotation(t *testing.T) {
442+ f := newKeyFixture(t)
443+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
444+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, true).Return(nil).Once()
445+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
446+ first, err := f.m.ShowKey(context.Background(), domain.Root())
447+ require.NoError(t, err)
448+ cached, err := f.m.ShowKey(context.Background(), domain.Root())
449+ require.NoError(t, err)
450+ require.Equal(t, first, cached)
451+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
452+ require.NoError(t, err)
453+ require.Equal(t, first.Fingerprint, rotated.Previous.Fingerprint)
454+ require.NotEqual(t, first.Fingerprint, rotated.Key.Fingerprint)
455+ require.Contains(t, rotated.Key.AuthorizedKey, "ermite-mirror@forge.example.com")
456+ require.Len(t, f.seeds(), 2)
457+ require.NotEqual(t, f.seeds()[0], f.seeds()[1])
458+}
459+
460+func TestMirrorsRotateWithoutExistingKeyGeneratesOnce(t *testing.T) {
461+ f := newKeyFixture(t)
462+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte(nil), time.Time{}, domain.ErrNotFound).Once()
463+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once() // never ifAbsent=true
464+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
465+ require.NoError(t, err)
466+ require.Empty(t, rotated.Previous.Fingerprint)
467+ require.NotEmpty(t, rotated.Key.Fingerprint)
468+ require.Len(t, f.seeds(), 1, "no throwaway key")
469+}
470+
471+func TestMirrorsRotateReplacesUnreadableKey(t *testing.T) {
472+ f := newKeyFixture(t)
473+ f.keys.EXPECT().GetMirrorKey(mock.Anything).Return([]byte("garbage"), now, nil).Once()
474+ f.sealerOpenFails = true
475+ f.keys.EXPECT().PutMirrorKey(mock.Anything, mock.Anything, now, false).Return(nil).Once()
476+ rotated, err := f.m.RotateKey(context.Background(), domain.Root())
477+ require.NoError(t, err)
478+ require.Empty(t, rotated.Previous.Fingerprint)
479+}
480+
481+func TestMirrorsAuthorizationAndMissingSecretsKey(t *testing.T) {
482+ f := newMirrorFixtureWith(t, func(c *usecase.MirrorsConfig) { c.Sealer = nil })
483+ _, err := f.m.ShowKey(context.Background(), domain.Anonymous())
484+ require.ErrorIs(t, err, domain.ErrUnauthenticated)
485+ _, err = f.m.ShowKey(context.Background(), domain.Root())
486+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
487+ require.Contains(t, err.Error(), "ERMITE_SECRETS_KEY")
488+ _, err = f.m.RotateKey(context.Background(), domain.Root())
489+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
490+ _, err = f.m.RotateKey(context.Background(), tokenPrincipal(domain.ScopeRepoRead))
491+ require.ErrorIs(t, err, domain.ErrForbidden)
492+}
M internal/usecase/pushes.go +10 -0
@@ -17,6 +17,7 @@ import (
1717
1818 // Pushes are the use cases around a Git push.
1919 type Pushes struct {
20+ mirror ports.MirrorTrigger // nil: no outbound mirror
2021 meta ports.RepoMetaStore
2122 repos *Repos
2223 git ports.GitRepos
@@ -43,6 +44,12 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p
4344 return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth}
4445 }
4546
47+// WithMirror makes Record notify outbound mirrors. Call before the first push.
48+func (u *Pushes) WithMirror(t ports.MirrorTrigger) *Pushes {
49+ u.mirror = t
50+ return u
51+}
52+
4653 // WithFreshener makes Record tell the reader that a repository changed, so that
4754 // it serves the push at once. Call it before the first push; it returns u.
4855 func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
@@ -262,6 +269,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
262269 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
263270 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
264271 })
272+ if u.mirror != nil {
273+ u.mirror.OnPush(ctx, name, updates) // marks only: never blocks
274+ }
265275 u.maint.Schedule(ctx, name)
266276 log.Info().
267277 Str("repo", name.String()).Int("refs", len(updates)).
M internal/usecase/repos.go +13 -5
@@ -17,11 +17,12 @@ import (
1717
1818 // Repos are the repository use cases.
1919 type Repos struct {
20- meta ports.RepoMetaStore
21- git ports.GitRepos
22- bus ports.EventBus
23- clock ports.Clock
24- ci ports.CIRunCleaner // nil: no CI logs to remove
20+ meta ports.RepoMetaStore
21+ git ports.GitRepos
22+ bus ports.EventBus
23+ clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
25+ mirror ports.MirrorTrigger // nil: no outbound mirror
2526 }
2627
2728 var _ ports.RepoService = (*Repos)(nil)
@@ -32,6 +33,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
3233 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
3334 }
3435
36+// WithMirror makes a default-branch change notify outbound mirrors. Call it
37+// before the first request; it returns r.
38+func (r *Repos) WithMirror(t ports.MirrorTrigger) *Repos {
39+ r.mirror = t
40+ return r
41+}
42+
3543 // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
3644 // before the first request; it returns r.
3745 func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
M internal/usecase/repos_write.go +3 -0
@@ -129,6 +129,9 @@ func (r *Repos) Update(ctx context.Context, p domain.Principal, name domain.Repo
129129 if !metaDone {
130130 return domain.Repo{}, headErr
131131 }
132+ } else if r.mirror != nil {
133+ // The default branch decides which refs are mirrored.
134+ r.mirror.OnPush(ctx, name, []domain.RefUpdate{{Ref: "HEAD"}})
132135 }
133136 }
134137
A proto/ermite/storage/mirror.proto +45 -0
@@ -0,0 +1,45 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+syntax = "proto3";
3+package ermite.storage;
4+import "google/protobuf/timestamp.proto";
5+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
6+message MirrorKey {
7+ bytes sealed = 1;
8+ google.protobuf.Timestamp created_at = 2;
9+}
10+message MirrorGlobal {
11+ bool enabled = 1;
12+ string url_template = 2;
13+ string cron = 3;
14+ bool all_branches = 4;
15+ google.protobuf.Timestamp updated_at = 5;
16+}
17+message MirrorRepo {
18+ string url = 1;
19+ string cron = 2;
20+ optional bool all_branches = 3;
21+ bool excluded = 4;
22+ google.protobuf.Timestamp updated_at = 5;
23+}
24+message MirrorState {
25+ string state = 1;
26+ string class = 2;
27+ string reason = 3;
28+ string detail = 4;
29+ int32 consecutive_failures = 5;
30+ google.protobuf.Timestamp last_attempt_at = 6;
31+ google.protobuf.Timestamp last_success_at = 7;
32+ google.protobuf.Timestamp next_retry_at = 8;
33+ google.protobuf.Timestamp paused_at = 9;
34+ google.protobuf.Timestamp updated_at = 10;
35+}
36+message MirrorSnapshotRef {
37+ string name = 1;
38+ string id = 2;
39+}
40+message MirrorSnapshot {
41+ string url = 1;
42+ repeated MirrorSnapshotRef refs = 2;
43+ bool all_branches = 3;
44+ google.protobuf.Timestamp at = 4;
45+}

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close