Merge 067ccd6a6bf0f9be62c0536cd1d13c79957cb708 into 55fcc8fd17ec2d8214aafd64e606433db222abb7
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 | |||||
| 132 | - [Configuration](docs/configuration.md): environment variables | 132 | - [Configuration](docs/configuration.md): environment variables | ||
| 133 | - [CLI](docs/cli.md): config file, commands, JSON output | 133 | - [CLI](docs/cli.md): config file, commands, JSON output | ||
| 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | 134 | - [Git over HTTP](docs/git-http.md): URLs and authentication | ||
| 135 | + | - [Push mirror](docs/mirror.md): push repositories to a remote over SSH | |||
| 135 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | 136 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | ||
| 136 | - [Merge requests](docs/merge-requests.md) | 137 | - [Merge requests](docs/merge-requests.md) | ||
| 137 | - [Issues](docs/issues.md) | 138 | - [Issues](docs/issues.md) | ||
M docs/ci-events.md +9 -0
| @@ -62,3 +62,12 @@ ermite workflow run <file> --repo <repo> [--ref <branch|tag>] [-f name=value … | |||||
| 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | ||
| 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | ||
| 64 | - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`. | 64 | - Payload: `ref`, `ref_type`, `pusher_type`, `repository`, `sender`; `create` adds `master_branch` and `description`. | ||
| 65 | + | ||||
| 66 | + | ## Mirror events | |||
| 67 | + | ||||
| 68 | + | Not workflow triggers: `ermite watch` reports them when a [push mirror](mirror.md) changes health, once per incident. | |||
| 69 | + | ||||
| 70 | + | - `mirror.failed`: the mirror started failing or was paused. Payload: `repo`, `state` (`failing` or `paused`), `failure_class` (`diverged`, `auth_denied`, `remote_not_found`, `host_key`, `transient`, `internal`). In `ermite watch --json` they are `mirror_failed` (with `state`, `failure_class`) and `mirror_recovered`. | |||
| 71 | + | - `mirror.recovered`: a failing or paused mirror pushed successfully. Payload: `repo`. | |||
| 72 | + | ||||
| 73 | + | The payload carries neither the remote URL, the reason nor the error detail; `ermite mirror status` shows the reason to repository admins. | |||
M docs/cli.md +8 -1
| @@ -75,6 +75,11 @@ ermite secret set|list|delete … # CI secrets (needs ERMITE_SECRETS_KEY | |||||
| 75 | ermite variable set|get|list|delete … | 75 | ermite variable set|get|list|delete … | ||
| 76 | ermite environment create|list|delete … --repo <repo> | 76 | ermite environment create|list|delete … --repo <repo> | ||
| 77 | 77 | ||||
| 78 | + | ermite mirror key show | rotate # SSH key to add to the remote (rotate: owner only) | |||
| 79 | + | ermite mirror config [--enabled] [--url …{repo}…] [--cron …] [--all-branches] | config show # global policy, owner only | |||
| 80 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] | remove <repo> | exclude <repo> | include <repo> | |||
| 81 | + | ermite mirror status [repo] | sync <repo>|--all [--force] # sync --all: one row per repository, exit 1 if any failed | |||
| 82 | + | ||||
| 78 | ermite web login # one-time sign-in link (60 s) | 83 | ermite web login # one-time sign-in link (60 s) | ||
| 79 | ermite web logout --all # sign every browser out | 84 | ermite web logout --all # sign every browser out | ||
| 80 | ermite web passkey list | remove <id> | 85 | ermite web passkey list | remove <id> | ||
| @@ -87,9 +92,11 @@ ermite completion bash|zsh|fish | |||||
| 87 | 92 | ||||
| 88 | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | 93 | Issues need a server with the `issues` feature and `ermite repo edit <repo> --issues owner` (or `--issues public`, which also opens a moderated form to visitors of a public repository); they are written with the `issue:write` scope. See [Issues](issues.md). | ||
| 89 | 94 | ||||
| 95 | + | Mirrors push repositories to a remote over SSH, never forcing. See [Push mirror](mirror.md). | |||
| 96 | + | ||||
| 90 | ## Output | 97 | ## Output | ||
| 91 | 98 | ||||
| 92 | - | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version`. | 99 | + | - `--json` prints stable field names (listed in each `--help`) on `repo list|show|create|edit`, `issue create|list|show|comment|close|reopen`, `token create|list`, `run list|view|cancel|rerun`, `runner list`, `mirror key|config|status` and `version`. |
| 93 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | 100 | - `watch --json` prints one object per line. `merge_request_mergeability` events carry `mergeability` (`state`, `behind`, `conflict_paths`…) and print as `repo!n mergeability: behind by 2`. | ||
| 94 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | 101 | - `mr merge --mode squash|merge` creates a commit signed by the server's own key (generated on first start); the output names its fingerprint, `ermite signing-key show` prints the key. Every mode is allowed by default; `repo edit --merge-modes fast-forward,squash` disables `merge`. | ||
| 95 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | 102 | - Errors are one line on stderr (`ermite: …`) with exit status 1. | ||
A docs/mirror.md +82 -0
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | # Push mirror | |||
| 2 | + | ||||
| 3 | + | Ermite can push its repositories to a remote Git host over SSH (GitHub, another forge…), to keep a read-only copy there. The push never forces and never deletes anything on the remote. | |||
| 4 | + | ||||
| 5 | + | ## Set up | |||
| 6 | + | ||||
| 7 | + | 1. The server needs `ERMITE_SECRETS_KEY`; it seals the mirror SSH key. Without it, syncs fail and nothing is stored. | |||
| 8 | + | 2. Add the server's public mirror key to the remote account or repository, as a key allowed to push: | |||
| 9 | + | ||||
| 10 | + | ```sh | |||
| 11 | + | ermite mirror key show | gh ssh-key add - --title "ermite mirror" | |||
| 12 | + | ``` | |||
| 13 | + | ||||
| 14 | + | `mirror key show` prints only the `authorized_keys` line. One key serves every mirror. | |||
| 15 | + | 3. Set the global policy (owner only). The URL template holds `{repo}`, replaced by the repository name; the cron is five fields, in UTC: | |||
| 16 | + | ||||
| 17 | + | ```sh | |||
| 18 | + | ermite mirror config --url 'git@github.com:acme/{repo}.git' --cron '*/15 * * * *' --enabled | |||
| 19 | + | ermite mirror config show | |||
| 20 | + | ``` | |||
| 21 | + | ||||
| 22 | + | `config` changes only the flags you pass. `--all-branches` mirrors every branch instead of the default branch. | |||
| 23 | + | 4. Adjust single repositories: | |||
| 24 | + | ||||
| 25 | + | ```sh | |||
| 26 | + | ermite mirror add <repo> <url> [--cron …] [--all-branches] # own URL (and schedule), even when the global policy is off | |||
| 27 | + | ermite mirror exclude <repo> # leave the global policy | |||
| 28 | + | ermite mirror include <repo> # back under it | |||
| 29 | + | ermite mirror remove <repo> # drop the override | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | Without `--cron` or `--all-branches`, `add` inherits the global values. | |||
| 33 | + | ||||
| 34 | + | URLs are `git@host:path` or `ssh://[user@]host[:port]/path`, with no password. The cron may not fire more often than every 5 minutes. | |||
| 35 | + | ||||
| 36 | + | ## What is pushed | |||
| 37 | + | ||||
| 38 | + | - The default branch and all tags; every branch with `--all-branches`. | |||
| 39 | + | - Never a force push, never a deletion: a branch or tag deleted here stays on the remote. | |||
| 40 | + | - The push is atomic: if the remote refuses **any** ref, nothing is updated and the mirror is **paused** as diverged. Besides a branch the remote has moved ahead, this happens when a tag was moved or rewritten here (an existing remote tag is never overwritten), and for any other ref the remote rejects. | |||
| 41 | + | - To fix a diverged branch, fast-forward ermite with the remote's commits (push them here). To fix a tag, restore it here to the commit the remote has, or delete that tag on the remote yourself (ermite never deletes anything there). Then run `ermite mirror sync <repo> --force`. | |||
| 42 | + | ||||
| 43 | + | ## When it syncs | |||
| 44 | + | ||||
| 45 | + | - On the cron schedule. A push to ermite only marks the repository as changed; the next due cron tick syncs it. A tick with nothing new does nothing. | |||
| 46 | + | - `ermite mirror sync <repo>` or `ermite mirror sync --all` (owner only) syncs now; a repository needs admin rights. `--all` skips repositories without a mirror, continues past failures, prints one row per repository and exits with status 1 if any failed. | |||
| 47 | + | - `--force` skips the no-op check and the 60 s throttle and lifts a pause. It never force-pushes. | |||
| 48 | + | - Overlapping syncs of a repository coalesce; only one push runs at a time per repository. | |||
| 49 | + | ||||
| 50 | + | ## Limits against spam | |||
| 51 | + | ||||
| 52 | + | | Rule | Value | | |||
| 53 | + | |---|---| | |||
| 54 | + | | No-op | nothing changed since the last successful push: no connection | | |||
| 55 | + | | Manual sync | one per repository per 60 s, unless `--force` | | |||
| 56 | + | | Cron | at least 5 minutes between firings | | |||
| 57 | + | | Backoff after a transient failure | 5 min, doubling, up to 6 h | | |||
| 58 | + | | Paused | after 5 consecutive transient failures | | |||
| 59 | + | | Paused at once | diverged remote, key refused, remote not found, host key changed | | |||
| 60 | + | ||||
| 61 | + | A paused mirror is not retried by the schedule. Changing the mirror configuration of a repository clears its failures. | |||
| 62 | + | ||||
| 63 | + | ## See what happens | |||
| 64 | + | ||||
| 65 | + | - `ermite mirror status [<repo>] [--json]`: state (`ok`, `failing`, `paused`, `disabled`), reason, last success, next run, failures. | |||
| 66 | + | - A `git push` to a repository whose mirror is failing or paused prints a `remote:` warning, **only to people with admin rights on the repository**: it names the remote and the reason, which other pushers do not see. The same holds for the URL and the reason in `mirror status` and the web badge. | |||
| 67 | + | - The repository page shows a badge to owners and admins. | |||
| 68 | + | - `ermite watch` receives `mirror.failed` once when a mirror starts failing, and `mirror.recovered` when it succeeds again. See [events](ci-events.md#mirror-events). | |||
| 69 | + | ||||
| 70 | + | ## Security | |||
| 71 | + | ||||
| 72 | + | - The private key is sealed at rest with `ERMITE_SECRETS_KEY`. It is never printed and never in logs; URLs are logged without credentials or user. | |||
| 73 | + | - Host keys are trusted on first use (TOFU), kept in `<data dir>/mirror_known_hosts`. The first connection to a host accepts whatever key it presents, so to be sure of it, pre-seed the file before configuring the mirror and compare with what the host publishes: | |||
| 74 | + | ||||
| 75 | + | ```sh | |||
| 76 | + | ssh-keyscan -t ed25519,rsa,ecdsa github.com | tee -a <data dir>/mirror_known_hosts | |||
| 77 | + | ssh-keygen -lf <data dir>/mirror_known_hosts # compare with https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints | |||
| 78 | + | ``` | |||
| 79 | + | ||||
| 80 | + | Remove any line whose fingerprint does not match. If a remote's key changes, the mirror is paused (`host_key`). After checking the change is legitimate, remove that host's line from the file and run `ermite mirror sync <repo> --force`. | |||
| 81 | + | - `ermite mirror key rotate` (owner only) makes a new key at once and prints the fingerprint of the replaced one on stderr. Every mirror fails until the new key is added to the remotes. | |||
| 82 | + | - The scheduler runs in the server process: run a single ermite instance per data directory. | |||
A gen/ermite/storage/mirror.pb.go +577 -0 generated file: collapsed
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||||
| 13 | 13 | ||||
| 14 | require ( | 14 | require ( | ||
| 15 | bnema.dev/ermite-actions v0.2.0 | 15 | bnema.dev/ermite-actions v0.2.0 | ||
| 16 | - | bnema.dev/ermite-api v0.11.0 | 16 | + | bnema.dev/ermite-api v0.12.0 |
| 17 | bnema.dev/ermite-git v0.1.0 | 17 | bnema.dev/ermite-git v0.1.0 | ||
| 18 | bnema.dev/ermite-kv v0.1.0 | 18 | bnema.dev/ermite-kv v0.1.0 | ||
| 19 | connectrpc.com/connect v1.21.0 | 19 | connectrpc.com/connect v1.21.0 | ||
M go.sum +2 -2 generated file: collapsed
M internal/adapters/client/client.go +5 -0
| @@ -60,6 +60,11 @@ func transport() *http.Transport { | |||||
| 60 | // URL is the server address the client talks to. | 60 | // URL is the server address the client talks to. | ||
| 61 | func (c *Client) URL() string { return c.cfg.URL } | 61 | func (c *Client) URL() string { return c.cfg.URL } | ||
| 62 | 62 | ||||
| 63 | + | // Mirrors returns the MirrorService client. | |||
| 64 | + | func (c *Client) Mirrors() ermiteconnect.MirrorServiceClient { | |||
| 65 | + | return ermiteconnect.NewMirrorServiceClient(c.http, c.base, c.opts...) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 63 | // Repos returns the RepoService client. | 68 | // Repos returns the RepoService client. | ||
| 64 | func (c *Client) Repos() ermiteconnect.RepoServiceClient { | 69 | func (c *Client) Repos() ermiteconnect.RepoServiceClient { | ||
| 65 | return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...) | 70 | return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...) | ||
M internal/adapters/githttp/hooks.go +30 -3
| @@ -37,8 +37,9 @@ type hookEntry struct { | |||||
| 37 | // HookServer registers the per-request hook tokens and serves the Unix | 37 | // HookServer registers the per-request hook tokens and serves the Unix | ||
| 38 | // socket the hooks call back on. It implements HookTokens. | 38 | // socket the hooks call back on. It implements HookTokens. | ||
| 39 | type HookServer struct { | 39 | type HookServer struct { | ||
| 40 | - | path string | 40 | + | path string |
| 41 | - | hooks ports.PushHookService | 41 | + | hooks ports.PushHookService |
| 42 | + | mirrors ports.MirrorWarner | |||
| 42 | 43 | ||||
| 43 | mu sync.RWMutex | 44 | mu sync.RWMutex | ||
| 44 | tokens map[string]hookEntry | 45 | tokens map[string]hookEntry | ||
| @@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer { | |||||
| 56 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | 57 | return &HookServer{path: path, hooks: hooks, tokens: make(map[string]hookEntry), conns: make(map[net.Conn]struct{})} | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 60 | + | // WithMirrorWarner enables best-effort mirror warnings. Configure before serving. | |||
| 61 | + | func (s *HookServer) WithMirrorWarner(w ports.MirrorWarner) *HookServer { | |||
| 62 | + | s.mirrors = w | |||
| 63 | + | return s | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | // mirrorWarning bounds even a source that does not honor context cancellation. | |||
| 67 | + | func (s *HookServer) mirrorWarning(ctx context.Context, p domain.Principal, repo domain.RepoName) string { | |||
| 68 | + | if s.mirrors == nil { | |||
| 69 | + | return "" | |||
| 70 | + | } | |||
| 71 | + | ctx, cancel := context.WithTimeout(ctx, 2*time.Second) | |||
| 72 | + | defer cancel() | |||
| 73 | + | result := make(chan string, 1) | |||
| 74 | + | go func() { result <- s.mirrors.Warning(ctx, p, repo) }() | |||
| 75 | + | select { | |||
| 76 | + | case warning := <-result: | |||
| 77 | + | if ctx.Err() != nil || warning == "" { | |||
| 78 | + | return "" | |||
| 79 | + | } | |||
| 80 | + | return warning + "\n→ ermite mirror status " + repo.String() | |||
| 81 | + | case <-ctx.Done(): | |||
| 82 | + | return "" | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 59 | // Socket returns the path of the socket. | 86 | // Socket returns the path of the socket. | ||
| 60 | func (s *HookServer) Socket() string { return s.path } | 87 | func (s *HookServer) Socket() string { return s.path } | ||
| 61 | 88 | ||||
| @@ -229,7 +256,7 @@ func (s *HookServer) handle(ctx context.Context, conn net.Conn) { | |||||
| 229 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | 256 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | ||
| 230 | return | 257 | return | ||
| 231 | } | 258 | } | ||
| 232 | - | s.reply(ctx, conn, HookReply{OK: true}) | 259 | + | s.reply(ctx, conn, HookReply{OK: true, Message: s.mirrorWarning(rctx, entry.principal, entry.repo)}) |
| 233 | } | 260 | } | ||
| 234 | } | 261 | } | ||
| 235 | 262 | ||||
M internal/adapters/githttp/hooks_test.go +46 -0
| @@ -412,3 +412,49 @@ func TestHookScriptExecsTheBinaryWithQuotedPath(t *testing.T) { | |||||
| 412 | require.NoError(t, err) | 412 | require.NoError(t, err) | ||
| 413 | assert.Equal(t, "line\n", string(in), "stdin reaches the binary") | 413 | assert.Equal(t, "line\n", string(in), "stdin reaches the binary") | ||
| 414 | } | 414 | } | ||
| 415 | + | ||||
| 416 | + | func TestMirrorPostReceiveWarning(t *testing.T) { | |||
| 417 | + | for _, warning := range []string{"ermite: mirror is failing: network unavailable", "ermite: mirror is paused: main diverged on remote", ""} { | |||
| 418 | + | t.Run(warning, func(t *testing.T) { | |||
| 419 | + | hooks := mocks.NewPushHookService(t) | |||
| 420 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |||
| 421 | + | srv, sock := startHooks(t, hooks) | |||
| 422 | + | w := mocks.NewMirrorWarner(t) | |||
| 423 | + | w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).Return(warning) | |||
| 424 | + | srv.WithMirrorWarner(w) | |||
| 425 | + | token, release := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |||
| 426 | + | defer release() | |||
| 427 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |||
| 428 | + | assert.True(t, reply.OK) | |||
| 429 | + | if warning == "" { | |||
| 430 | + | assert.Empty(t, reply.Message) | |||
| 431 | + | } else { | |||
| 432 | + | assert.Equal(t, warning+"\n→ ermite mirror status proj", reply.Message) | |||
| 433 | + | } | |||
| 434 | + | }) | |||
| 435 | + | } | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | func TestMirrorPostReceiveTimeout(t *testing.T) { | |||
| 439 | + | hooks := mocks.NewPushHookService(t) | |||
| 440 | + | hooks.EXPECT().Record(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) | |||
| 441 | + | srv, sock := startHooks(t, hooks) | |||
| 442 | + | w := mocks.NewMirrorWarner(t) | |||
| 443 | + | release := make(chan struct{}) | |||
| 444 | + | defer close(release) | |||
| 445 | + | w.EXPECT().Warning(mock.Anything, domain.Root(), repoName(t, "proj")).RunAndReturn(func(ctx context.Context, _ domain.Principal, _ domain.RepoName) string { | |||
| 446 | + | deadline, ok := ctx.Deadline() | |||
| 447 | + | assert.True(t, ok) | |||
| 448 | + | assert.LessOrEqual(t, time.Until(deadline), 2*time.Second) | |||
| 449 | + | <-release // deliberately ignores cancellation | |||
| 450 | + | return "late warning" | |||
| 451 | + | }) | |||
| 452 | + | srv.WithMirrorWarner(w) | |||
| 453 | + | token, revoke := srv.Register(t.Context(), repoName(t, "proj"), domain.Root()) | |||
| 454 | + | defer revoke() | |||
| 455 | + | start := time.Now() | |||
| 456 | + | reply := raw(t, sock, `{"token":"`+token+`","hook":"post-receive","updates":[]}`) | |||
| 457 | + | assert.True(t, reply.OK) | |||
| 458 | + | assert.Empty(t, reply.Message) | |||
| 459 | + | assert.Less(t, time.Since(start), 2500*time.Millisecond) | |||
| 460 | + | } | |||
A internal/adapters/gitpush/gitpush.go +258 -0
| @@ -0,0 +1,258 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package gitpush implements bounded, non-forcing mirror pushes over git/ssh. | |||
| 4 | + | package gitpush | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "bytes" | |||
| 8 | + | "context" | |||
| 9 | + | "crypto/ed25519" | |||
| 10 | + | "encoding/pem" | |||
| 11 | + | "errors" | |||
| 12 | + | "os" | |||
| 13 | + | "os/exec" | |||
| 14 | + | "strings" | |||
| 15 | + | "time" | |||
| 16 | + | "unicode" | |||
| 17 | + | "unicode/utf8" | |||
| 18 | + | ||||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | "bnema.dev/ermite/internal/ports" | |||
| 21 | + | "github.com/bnema/zerowrap" | |||
| 22 | + | "golang.org/x/crypto/ssh" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | type Config struct { | |||
| 26 | + | Bin, Path, Home, KnownHosts, TempDir string | |||
| 27 | + | Timeout time.Duration | |||
| 28 | + | MaxOutput int | |||
| 29 | + | AllowedProtocols string | |||
| 30 | + | SSHOptions []string | |||
| 31 | + | RepoDir func(domain.RepoName) string | |||
| 32 | + | } | |||
| 33 | + | type Pusher struct{ cfg Config } | |||
| 34 | + | ||||
| 35 | + | var _ ports.MirrorPusher = (*Pusher)(nil) | |||
| 36 | + | ||||
| 37 | + | func New(cfg Config) *Pusher { | |||
| 38 | + | if cfg.Timeout <= 0 { | |||
| 39 | + | cfg.Timeout = 10 * time.Minute | |||
| 40 | + | } | |||
| 41 | + | if cfg.MaxOutput <= 0 { | |||
| 42 | + | cfg.MaxOutput = 1 << 20 | |||
| 43 | + | } | |||
| 44 | + | if cfg.AllowedProtocols == "" { | |||
| 45 | + | cfg.AllowedProtocols = "ssh" | |||
| 46 | + | } | |||
| 47 | + | cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...) | |||
| 48 | + | return &Pusher{cfg: cfg} | |||
| 49 | + | } | |||
| 50 | + | func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } | |||
| 51 | + | ||||
| 52 | + | // validRefspec deliberately accepts only explicit same-name heads and tags. | |||
| 53 | + | // Empty sources, wildcards, force prefixes and revision expressions never reach git. | |||
| 54 | + | func validRefspec(s string) bool { | |||
| 55 | + | a, b, ok := strings.Cut(s, ":") | |||
| 56 | + | if !ok || a != b || !strings.HasPrefix(a, "refs/heads/") && !strings.HasPrefix(a, "refs/tags/") { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") { | |||
| 60 | + | return false | |||
| 61 | + | } | |||
| 62 | + | for _, part := range strings.Split(a, "/") { | |||
| 63 | + | if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | return !strings.ContainsFunc(a, unicode.IsControl) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) { | |||
| 71 | + | start := time.Now() | |||
| 72 | + | defer func() { result.Duration = time.Since(start) }() | |||
| 73 | + | internal := func(detail string) error { | |||
| 74 | + | return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |||
| 75 | + | } | |||
| 76 | + | if err := parent.Err(); err != nil { | |||
| 77 | + | return result, err | |||
| 78 | + | } | |||
| 79 | + | if len(req.PrivateKey) != ed25519.PrivateKeySize { | |||
| 80 | + | return result, internal("invalid mirror private key") | |||
| 81 | + | } | |||
| 82 | + | if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" { | |||
| 83 | + | return result, internal("mirror transport directories are not configured") | |||
| 84 | + | } | |||
| 85 | + | if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) { | |||
| 86 | + | return result, internal("invalid mirror target") | |||
| 87 | + | } | |||
| 88 | + | for _, ref := range req.Refspecs { | |||
| 89 | + | if !validRefspec(ref) { | |||
| 90 | + | return result, internal("invalid mirror refspec") | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | if len(req.Refspecs) == 0 { | |||
| 94 | + | return result, nil | |||
| 95 | + | } // never let git select implicit refspecs | |||
| 96 | + | ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout) | |||
| 97 | + | defer cancel() | |||
| 98 | + | if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil { | |||
| 99 | + | return result, internal("create mirror key directory") | |||
| 100 | + | } | |||
| 101 | + | if err := os.Chmod(p.cfg.TempDir, 0700); err != nil { | |||
| 102 | + | return result, internal("protect mirror key directory") | |||
| 103 | + | } | |||
| 104 | + | dir, e := os.MkdirTemp(p.cfg.TempDir, "push-") | |||
| 105 | + | if e != nil { | |||
| 106 | + | return result, internal("create temporary mirror directory") | |||
| 107 | + | } | |||
| 108 | + | defer func() { | |||
| 109 | + | // The directory holds the private key: a leftover is reported, and resetRunDir wipes it at | |||
| 110 | + | // the next start. | |||
| 111 | + | if e := os.RemoveAll(dir); e != nil { | |||
| 112 | + | log := zerowrap.FromCtx(parent) | |||
| 113 | + | log.Warn().Err(e).Msg("remove the temporary mirror key directory") | |||
| 114 | + | } | |||
| 115 | + | }() | |||
| 116 | + | block, e := ssh.MarshalPrivateKey(req.PrivateKey, "") | |||
| 117 | + | if e != nil { | |||
| 118 | + | return result, internal("encode mirror private key") | |||
| 119 | + | } | |||
| 120 | + | f, e := os.CreateTemp(dir, "key-") | |||
| 121 | + | if e != nil { | |||
| 122 | + | return result, internal("create temporary mirror key") | |||
| 123 | + | } | |||
| 124 | + | keyPath := f.Name() | |||
| 125 | + | _, e = f.Write(pem.EncodeToMemory(block)) | |||
| 126 | + | closeErr := f.Close() | |||
| 127 | + | if e != nil || closeErr != nil { | |||
| 128 | + | return result, internal("write temporary mirror key") | |||
| 129 | + | } | |||
| 130 | + | // -F /dev/null prevents ~/.ssh/config and system config from adding credentials | |||
| 131 | + | // or weakening the required options. Required options precede caller extras. | |||
| 132 | + | command := "ssh -F /dev/null -i " + quote(keyPath) | |||
| 133 | + | for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts, "ConnectTimeout=30", "ServerAliveInterval=15", "ServerAliveCountMax=3"}, p.cfg.SSHOptions...) { | |||
| 134 | + | command += " -o " + quote(opt) | |||
| 135 | + | } | |||
| 136 | + | cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond) | |||
| 137 | + | runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond)) | |||
| 138 | + | defer stop() | |||
| 139 | + | stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |||
| 140 | + | stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |||
| 141 | + | args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL} | |||
| 142 | + | args = append(args, req.Refspecs...) | |||
| 143 | + | cmd := exec.Command(p.cfg.Bin, args...) | |||
| 144 | + | cmd.Env = []string{"PATH=" + p.cfg.Path, "HOME=" + p.cfg.Home, "GIT_ALLOW_PROTOCOL=" + p.cfg.AllowedProtocols, "GIT_TERMINAL_PROMPT=0", "GIT_CONFIG_NOSYSTEM=1", "GIT_SSH_COMMAND=" + command, "LC_ALL=C"} | |||
| 145 | + | cmd.Dir = p.cfg.Home | |||
| 146 | + | cmd.Stdout = stdout | |||
| 147 | + | cmd.Stderr = stderr | |||
| 148 | + | cmd.WaitDelay = cleanup | |||
| 149 | + | runErr := run(runCtx, cmd) | |||
| 150 | + | if parent.Err() != nil { | |||
| 151 | + | return result, parent.Err() | |||
| 152 | + | } | |||
| 153 | + | rejected := false | |||
| 154 | + | for line := range strings.SplitSeq(stdout.buf.String(), "\n") { | |||
| 155 | + | fields := strings.Split(line, "\t") | |||
| 156 | + | if len(fields) < 3 || len(fields[0]) != 1 { | |||
| 157 | + | continue | |||
| 158 | + | } | |||
| 159 | + | var status ports.MirrorRefStatus | |||
| 160 | + | switch fields[0] { | |||
| 161 | + | case " ", "*": | |||
| 162 | + | status = ports.MirrorRefOK | |||
| 163 | + | case "=": | |||
| 164 | + | status = ports.MirrorRefUpToDate | |||
| 165 | + | case "!": | |||
| 166 | + | status = ports.MirrorRefRejected | |||
| 167 | + | rejected = true | |||
| 168 | + | default: | |||
| 169 | + | continue | |||
| 170 | + | } | |||
| 171 | + | _, ref, _ := strings.Cut(fields[1], ":") | |||
| 172 | + | result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)}) | |||
| 173 | + | } | |||
| 174 | + | if stdout.exceeded || stderr.exceeded { | |||
| 175 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"} | |||
| 176 | + | } | |||
| 177 | + | if runCtx.Err() != nil { | |||
| 178 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"} | |||
| 179 | + | } | |||
| 180 | + | if runErr != nil || rejected { | |||
| 181 | + | // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting | |||
| 182 | + | // message must not pick the failure class. They stay in the detail. | |||
| 183 | + | class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false) | |||
| 184 | + | if rejected && class == domain.MirrorClassNone { | |||
| 185 | + | class = domain.MirrorClassDiverged | |||
| 186 | + | } | |||
| 187 | + | var execErr *exec.Error | |||
| 188 | + | if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) { | |||
| 189 | + | class = domain.MirrorClassInternal | |||
| 190 | + | } | |||
| 191 | + | detail := safeDetail(stderr.buf.String(), req.URL) | |||
| 192 | + | if detail == "" { | |||
| 193 | + | detail = "git push failed" | |||
| 194 | + | } | |||
| 195 | + | return result, &domain.MirrorError{Class: class, Detail: detail} | |||
| 196 | + | } | |||
| 197 | + | return result, nil | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // withoutRemoteLines drops the lines the remote sent through git ("remote: ..."). | |||
| 201 | + | func withoutRemoteLines(s string) string { | |||
| 202 | + | lines := strings.Split(s, "\n") | |||
| 203 | + | kept := lines[:0] | |||
| 204 | + | for _, l := range lines { | |||
| 205 | + | if !strings.HasPrefix(strings.TrimSpace(l), "remote:") { | |||
| 206 | + | kept = append(kept, l) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | return strings.Join(kept, "\n") | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func localGitError(stderr string) bool { | |||
| 213 | + | for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} { | |||
| 214 | + | if strings.Contains(strings.ToLower(stderr), marker) { | |||
| 215 | + | return true | |||
| 216 | + | } | |||
| 217 | + | } | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired. | |||
| 222 | + | const maxDetail = 4096 | |||
| 223 | + | ||||
| 224 | + | // safeDetail removes the complete target before taking a bounded stderr tail. | |||
| 225 | + | // SSH diagnostics may repeat the user separately, so suppress user@ tokens too. | |||
| 226 | + | func safeDetail(s, url string) string { | |||
| 227 | + | s = strings.ReplaceAll(s, url, "[mirror target]") | |||
| 228 | + | words := strings.Fields(s) | |||
| 229 | + | for i, w := range words { | |||
| 230 | + | if strings.Contains(w, "@") || strings.Contains(w, "://") { | |||
| 231 | + | words[i] = "[redacted]" | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | s = strings.Join(words, " ") | |||
| 235 | + | if len(s) > maxDetail { | |||
| 236 | + | s = s[len(s)-maxDetail:] | |||
| 237 | + | for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character | |||
| 238 | + | s = s[1:] | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | return strings.ToValidUTF8(s, "?") | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | type limitWriter struct { | |||
| 245 | + | buf bytes.Buffer | |||
| 246 | + | max int | |||
| 247 | + | exceeded bool | |||
| 248 | + | cancel context.CancelFunc | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |||
| 252 | + | if len(p) > w.max-w.buf.Len() { | |||
| 253 | + | w.exceeded = true | |||
| 254 | + | w.cancel() | |||
| 255 | + | return 0, errors.New("output limit reached") | |||
| 256 | + | } | |||
| 257 | + | return w.buf.Write(p) | |||
| 258 | + | } | |||
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 | |||||
| 65 | _, err := i.db.Apply(ctx, &b) | 65 | _, err := i.db.Apply(ctx, &b) | ||
| 66 | return err | 66 | return err | ||
| 67 | } | 67 | } | ||
| 68 | + | ||||
| 69 | + | // RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests. | |||
| 70 | + | func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) { | |||
| 71 | + | item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath)) | |||
| 72 | + | return item.Value, ok | |||
| 73 | + | } | |||
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 { | |||||
| 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | ||
| 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | ||
| 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | ||
| 167 | + | keys = append(keys, mirrorKeys(ctx, snap, name)...) | |||
| 167 | for len(keys) > deleteChunk { | 168 | for len(keys) > deleteChunk { | ||
| 168 | var b kv.Batch | 169 | var b kv.Batch | ||
| 169 | for _, k := range keys[:deleteChunk] { | 170 | for _, k := range keys[:deleteChunk] { | ||
M internal/adapters/rpc/api.go +11 -5
| @@ -47,11 +47,13 @@ const FeatureCIOIDC = "ci.oidc" | |||||
| 47 | 47 | ||||
| 48 | // Config configures the API. | 48 | // Config configures the API. | ||
| 49 | type Config struct { | 49 | type Config struct { | ||
| 50 | - | Auth ports.AuthService | 50 | + | // Mirrors serves MirrorService when set; leave unset rather than a typed nil. |
| 51 | - | Repos ports.RepoService | 51 | + | Mirrors ports.MirrorService |
| 52 | - | Tokens ports.TokenService | 52 | + | Auth ports.AuthService |
| 53 | - | Browse ports.BrowseService | 53 | + | Repos ports.RepoService |
| 54 | - | Events ports.EventService | 54 | + | Tokens ports.TokenService |
| 55 | + | Browse ports.BrowseService | |||
| 56 | + | Events ports.EventService | |||
| 55 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | 57 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | ||
| 56 | MergeRequests ports.MergeRequestService | 58 | MergeRequests ports.MergeRequestService | ||
| 57 | SigningKey ports.SigningKeyService | 59 | SigningKey ports.SigningKeyService | ||
| @@ -125,6 +127,10 @@ func New(cfg Config) *API { | |||||
| 125 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | 127 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | ||
| 126 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | 128 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | ||
| 127 | features := slices.Clone(Features) | 129 | features := slices.Clone(Features) | ||
| 130 | + | if cfg.Mirrors != nil { | |||
| 131 | + | mux.Handle(ermiteconnect.NewMirrorServiceHandler(&mirrorService{cfg.Mirrors}, opts...)) | |||
| 132 | + | features = append(features, FeatureMirror) | |||
| 133 | + | } | |||
| 128 | if cfg.Passkeys != nil { | 134 | if cfg.Passkeys != nil { | ||
| 129 | features = append(features, FeaturePasskeys) | 135 | features = append(features, FeaturePasskeys) | ||
| 130 | } | 136 | } | ||
M internal/adapters/rpc/convert.go +6 -0
| @@ -523,6 +523,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event { | |||||
| 523 | case domain.JobUpdated: | 523 | case domain.JobUpdated: | ||
| 524 | out.Time = timestamp(ev.At) | 524 | out.Time = timestamp(ev.At) | ||
| 525 | out.Kind = jobEventToPB(ev) | 525 | out.Kind = jobEventToPB(ev) | ||
| 526 | + | case domain.MirrorFailed: | |||
| 527 | + | out.Time = timestamp(ev.At) | |||
| 528 | + | out.Kind = &pb.Event_MirrorFailed{MirrorFailed: &pb.MirrorFailed{Repo: ev.Repo.String(), State: mirrorStateToPB(ev.State), FailureClass: string(ev.Class)}} | |||
| 529 | + | case domain.MirrorRecovered: | |||
| 530 | + | out.Time = timestamp(ev.At) | |||
| 531 | + | out.Kind = &pb.Event_MirrorRecovered{MirrorRecovered: &pb.MirrorRecovered{Repo: ev.Repo.String()}} | |||
| 526 | default: | 532 | default: | ||
| 527 | // Web-only events, such as domain.IssuePendingRemoved (the API has | 533 | // Web-only events, such as domain.IssuePendingRemoved (the API has | ||
| 528 | // no message for it; watchers list pending issues on demand). | 534 | // no message for it; watchers list pending issues on demand). | ||
A internal/adapters/rpc/mirror.go +204 -0
| @@ -0,0 +1,204 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "fmt" | |||
| 9 | + | ||||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 12 | + | "bnema.dev/ermite/internal/domain" | |||
| 13 | + | "bnema.dev/ermite/internal/ports" | |||
| 14 | + | "connectrpc.com/connect" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const FeatureMirror = "mirror" | |||
| 18 | + | ||||
| 19 | + | type mirrorService struct{ mirrors ports.MirrorService } | |||
| 20 | + | ||||
| 21 | + | var _ ermiteconnect.MirrorServiceHandler = (*mirrorService)(nil) | |||
| 22 | + | ||||
| 23 | + | func (s *mirrorService) GetMirrorKey(ctx context.Context, _ *connect.Request[pb.GetMirrorKeyRequest]) (*connect.Response[pb.GetMirrorKeyResponse], error) { | |||
| 24 | + | k, err := s.mirrors.ShowKey(ctx, caller(ctx)) | |||
| 25 | + | if err != nil { | |||
| 26 | + | return nil, connectError(ctx, err) | |||
| 27 | + | } | |||
| 28 | + | return connect.NewResponse(&pb.GetMirrorKeyResponse{Key: mirrorKeyToPB(k)}), nil | |||
| 29 | + | } | |||
| 30 | + | func (s *mirrorService) RotateMirrorKey(ctx context.Context, _ *connect.Request[pb.RotateMirrorKeyRequest]) (*connect.Response[pb.RotateMirrorKeyResponse], error) { | |||
| 31 | + | r, err := s.mirrors.RotateKey(ctx, caller(ctx)) | |||
| 32 | + | if err != nil { | |||
| 33 | + | return nil, connectError(ctx, err) | |||
| 34 | + | } | |||
| 35 | + | return connect.NewResponse(&pb.RotateMirrorKeyResponse{Key: mirrorKeyToPB(r.Key), PreviousFingerprint: r.Previous.Fingerprint}), nil | |||
| 36 | + | } | |||
| 37 | + | func (s *mirrorService) GetMirrorConfig(ctx context.Context, _ *connect.Request[pb.GetMirrorConfigRequest]) (*connect.Response[pb.GetMirrorConfigResponse], error) { | |||
| 38 | + | g, err := s.mirrors.GetGlobalConfig(ctx, caller(ctx)) | |||
| 39 | + | if err != nil { | |||
| 40 | + | return nil, connectError(ctx, err) | |||
| 41 | + | } | |||
| 42 | + | return connect.NewResponse(&pb.GetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil | |||
| 43 | + | } | |||
| 44 | + | func (s *mirrorService) SetMirrorConfig(ctx context.Context, req *connect.Request[pb.SetMirrorConfigRequest]) (*connect.Response[pb.SetMirrorConfigResponse], error) { | |||
| 45 | + | m := req.Msg | |||
| 46 | + | g, err := s.mirrors.SetGlobalConfig(ctx, caller(ctx), ports.MirrorGlobalPatch{Enabled: m.Enabled, URLTemplate: m.UrlTemplate, Cron: m.Cron, AllBranches: m.AllBranches}) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return nil, connectError(ctx, err) | |||
| 49 | + | } | |||
| 50 | + | return connect.NewResponse(&pb.SetMirrorConfigResponse{Config: mirrorConfigToPB(g)}), nil | |||
| 51 | + | } | |||
| 52 | + | func (s *mirrorService) SetRepoMirror(ctx context.Context, req *connect.Request[pb.SetRepoMirrorRequest]) (*connect.Response[pb.SetRepoMirrorResponse], error) { | |||
| 53 | + | m := req.Msg | |||
| 54 | + | name, err := domain.ParseRepoName(m.GetRepo()) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, connectError(ctx, err) | |||
| 57 | + | } | |||
| 58 | + | t, err := s.mirrors.SetRepoMirror(ctx, caller(ctx), domain.MirrorRepoConfig{Repo: name, URL: m.GetUrl(), Cron: m.GetCron(), AllBranches: m.AllBranches}) | |||
| 59 | + | if err != nil { | |||
| 60 | + | return nil, connectError(ctx, err) | |||
| 61 | + | } | |||
| 62 | + | return connect.NewResponse(&pb.SetRepoMirrorResponse{Target: mirrorTargetToPB(t)}), nil | |||
| 63 | + | } | |||
| 64 | + | func (s *mirrorService) RemoveRepoMirror(ctx context.Context, req *connect.Request[pb.RemoveRepoMirrorRequest]) (*connect.Response[pb.RemoveRepoMirrorResponse], error) { | |||
| 65 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |||
| 66 | + | if err != nil { | |||
| 67 | + | return nil, connectError(ctx, err) | |||
| 68 | + | } | |||
| 69 | + | if err := s.mirrors.RemoveRepoMirror(ctx, caller(ctx), name); err != nil { | |||
| 70 | + | return nil, connectError(ctx, err) | |||
| 71 | + | } | |||
| 72 | + | return connect.NewResponse(&pb.RemoveRepoMirrorResponse{}), nil | |||
| 73 | + | } | |||
| 74 | + | func (s *mirrorService) SetRepoMirrorExcluded(ctx context.Context, req *connect.Request[pb.SetRepoMirrorExcludedRequest]) (*connect.Response[pb.SetRepoMirrorExcludedResponse], error) { | |||
| 75 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return nil, connectError(ctx, err) | |||
| 78 | + | } | |||
| 79 | + | t, err := s.mirrors.SetRepoExcluded(ctx, caller(ctx), name, req.Msg.GetExcluded()) | |||
| 80 | + | if err != nil { | |||
| 81 | + | return nil, connectError(ctx, err) | |||
| 82 | + | } | |||
| 83 | + | return connect.NewResponse(&pb.SetRepoMirrorExcludedResponse{Target: mirrorTargetToPB(t)}), nil | |||
| 84 | + | } | |||
| 85 | + | func (s *mirrorService) GetMirrorStatus(ctx context.Context, req *connect.Request[pb.GetMirrorStatusRequest]) (*connect.Response[pb.GetMirrorStatusResponse], error) { | |||
| 86 | + | name, err := domain.ParseRepoName(req.Msg.GetRepo()) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return nil, connectError(ctx, err) | |||
| 89 | + | } | |||
| 90 | + | v, err := s.mirrors.GetStatus(ctx, caller(ctx), name) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return nil, connectError(ctx, err) | |||
| 93 | + | } | |||
| 94 | + | return connect.NewResponse(&pb.GetMirrorStatusResponse{Status: mirrorStatusToPB(v)}), nil | |||
| 95 | + | } | |||
| 96 | + | func (s *mirrorService) ListMirrorStatus(ctx context.Context, _ *connect.Request[pb.ListMirrorStatusRequest]) (*connect.Response[pb.ListMirrorStatusResponse], error) { | |||
| 97 | + | list, err := s.mirrors.ListStatus(ctx, caller(ctx)) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return nil, connectError(ctx, err) | |||
| 100 | + | } | |||
| 101 | + | out := make([]*pb.MirrorStatus, 0, len(list)) | |||
| 102 | + | for _, v := range list { | |||
| 103 | + | out = append(out, mirrorStatusToPB(v)) | |||
| 104 | + | } | |||
| 105 | + | return connect.NewResponse(&pb.ListMirrorStatusResponse{Statuses: out}), nil | |||
| 106 | + | } | |||
| 107 | + | func (s *mirrorService) SyncMirror(ctx context.Context, req *connect.Request[pb.SyncMirrorRequest]) (*connect.Response[pb.SyncMirrorResponse], error) { | |||
| 108 | + | m := req.Msg | |||
| 109 | + | if m.GetAll() == (m.GetRepo() != "") { | |||
| 110 | + | return nil, connectError(ctx, fmt.Errorf("%w: specify either repo or all", domain.ErrInvalid)) | |||
| 111 | + | } | |||
| 112 | + | var results []ports.MirrorSyncResult | |||
| 113 | + | if m.GetAll() { | |||
| 114 | + | list, err := s.mirrors.SyncAll(ctx, caller(ctx), m.GetForce()) | |||
| 115 | + | if err != nil { | |||
| 116 | + | return nil, connectError(ctx, err) | |||
| 117 | + | } | |||
| 118 | + | results = list | |||
| 119 | + | } else { | |||
| 120 | + | name, err := domain.ParseRepoName(m.GetRepo()) | |||
| 121 | + | if err != nil { | |||
| 122 | + | return nil, connectError(ctx, err) | |||
| 123 | + | } | |||
| 124 | + | result, err := s.mirrors.Sync(ctx, caller(ctx), name, m.GetForce()) | |||
| 125 | + | if err != nil { | |||
| 126 | + | return nil, connectError(ctx, err) | |||
| 127 | + | } | |||
| 128 | + | result.Repo = name | |||
| 129 | + | results = []ports.MirrorSyncResult{result} | |||
| 130 | + | } | |||
| 131 | + | out := make([]*pb.SyncMirrorResult, 0, len(results)) | |||
| 132 | + | for _, result := range results { | |||
| 133 | + | v, err := s.mirrors.GetStatus(ctx, caller(ctx), result.Repo) | |||
| 134 | + | if err != nil { | |||
| 135 | + | return nil, connectError(ctx, err) | |||
| 136 | + | } | |||
| 137 | + | if !result.Queued { | |||
| 138 | + | v.Status = result.Status | |||
| 139 | + | } | |||
| 140 | + | item := &pb.SyncMirrorResult{Repo: result.Repo.String(), Queued: result.Queued, NoOp: result.NoOp, Status: mirrorStatusToPB(v)} | |||
| 141 | + | if result.Err != nil { | |||
| 142 | + | item.Error = syncErrorText(result.Err) | |||
| 143 | + | } | |||
| 144 | + | out = append(out, item) | |||
| 145 | + | } | |||
| 146 | + | return connect.NewResponse(&pb.SyncMirrorResponse{Results: out}), nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // syncErrorText is the short reason a repository failed in SyncAll: the failure class only. | |||
| 150 | + | // The remote's output stays in GetMirrorStatus, so the response stays small and URL-free. | |||
| 151 | + | func syncErrorText(err error) string { | |||
| 152 | + | var me *domain.MirrorError | |||
| 153 | + | if errors.As(err, &me) { | |||
| 154 | + | return "mirror " + string(me.Class) + ": see ermite mirror status" | |||
| 155 | + | } | |||
| 156 | + | switch { | |||
| 157 | + | case errors.Is(err, domain.ErrResourceExhausted): | |||
| 158 | + | return err.Error() | |||
| 159 | + | case errors.Is(err, domain.ErrNotFound): | |||
| 160 | + | return "repository not found" | |||
| 161 | + | case errors.Is(err, domain.ErrInvalid): | |||
| 162 | + | return "invalid mirror configuration" | |||
| 163 | + | } | |||
| 164 | + | return "sync failed" | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func mirrorKeyToPB(k domain.MirrorPublicKey) *pb.MirrorKey { | |||
| 168 | + | return &pb.MirrorKey{AuthorizedKey: k.AuthorizedKey, Fingerprint: k.Fingerprint, CreatedAt: timestamp(k.CreatedAt)} | |||
| 169 | + | } | |||
| 170 | + | func mirrorConfigToPB(g domain.MirrorGlobalConfig) *pb.MirrorConfig { | |||
| 171 | + | return &pb.MirrorConfig{Enabled: g.Enabled, UrlTemplate: g.URLTemplate, Cron: g.Cron, AllBranches: g.AllBranches, UpdatedAt: timestamp(g.UpdatedAt)} | |||
| 172 | + | } | |||
| 173 | + | func mirrorStateToPB(s domain.MirrorState) pb.MirrorState { | |||
| 174 | + | switch s { | |||
| 175 | + | case domain.MirrorDisabled: | |||
| 176 | + | return pb.MirrorState_MIRROR_STATE_DISABLED | |||
| 177 | + | case domain.MirrorOK: | |||
| 178 | + | return pb.MirrorState_MIRROR_STATE_OK | |||
| 179 | + | case domain.MirrorFailing: | |||
| 180 | + | return pb.MirrorState_MIRROR_STATE_FAILING | |||
| 181 | + | case domain.MirrorPaused: | |||
| 182 | + | return pb.MirrorState_MIRROR_STATE_PAUSED | |||
| 183 | + | default: | |||
| 184 | + | return pb.MirrorState_MIRROR_STATE_UNSPECIFIED | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | func mirrorTargetToPB(t domain.MirrorTarget) *pb.MirrorTarget { | |||
| 188 | + | source := pb.MirrorSource_MIRROR_SOURCE_UNSPECIFIED | |||
| 189 | + | switch t.Source { | |||
| 190 | + | case domain.MirrorSourceNone: | |||
| 191 | + | source = pb.MirrorSource_MIRROR_SOURCE_DISABLED | |||
| 192 | + | case domain.MirrorSourceGlobal: | |||
| 193 | + | source = pb.MirrorSource_MIRROR_SOURCE_GLOBAL | |||
| 194 | + | case domain.MirrorSourceOverride: | |||
| 195 | + | source = pb.MirrorSource_MIRROR_SOURCE_OVERRIDE | |||
| 196 | + | case domain.MirrorSourceExcluded: | |||
| 197 | + | source = pb.MirrorSource_MIRROR_SOURCE_EXCLUDED | |||
| 198 | + | } | |||
| 199 | + | return &pb.MirrorTarget{Repo: t.Repo.String(), Enabled: t.Enabled, Url: t.URL, Cron: t.CronSpec, AllBranches: t.AllBranches, Source: source} | |||
| 200 | + | } | |||
| 201 | + | func mirrorStatusToPB(v ports.MirrorStatusView) *pb.MirrorStatus { | |||
| 202 | + | s := v.Status | |||
| 203 | + | return &pb.MirrorStatus{Target: mirrorTargetToPB(v.Target), State: mirrorStateToPB(s.State), FailureClass: string(s.Class), Reason: s.Reason, Detail: s.Detail, ConsecutiveFailures: int32(s.ConsecutiveFailures), LastAttemptAt: timestamp(s.LastAttemptAt), LastSuccessAt: timestamp(s.LastSuccessAt), NextRetryAt: timestamp(s.NextRetryAt), NextRunAt: timestamp(v.NextRunAt), PausedAt: timestamp(s.PausedAt)} | |||
| 204 | + | } | |||
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) { | |||||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | templ summaryRail(p SummaryPage) { | 94 | templ summaryRail(p SummaryPage) { | ||
| 95 | + | if p.Mirror != nil { | |||
| 96 | + | <section aria-labelledby="r-mirror"> | |||
| 97 | + | <h2 id="r-mirror">Mirror</h2> | |||
| 98 | + | <p>⚠ Mirror { p.Mirror.State }</p> | |||
| 99 | + | if p.Mirror.Reason != "" { | |||
| 100 | + | <p>{ p.Mirror.Reason }</p> | |||
| 101 | + | } | |||
| 102 | + | <p>Run <code>{ p.Mirror.Command }</code></p> | |||
| 103 | + | </section> | |||
| 104 | + | } | |||
| 105 | + | ||||
| 95 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | 106 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | ||
| 96 | </section> | 107 | </section> | ||
| 97 | <section aria-labelledby="r-facts"> | 108 | <section aria-labelledby="r-facts"> | ||
M internal/adapters/web/pages_templ.go +324 -269 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, | |||||
| 49 | now := s.cfg.Now() | 49 | now := s.cfg.Now() | ||
| 50 | 50 | ||||
| 51 | p := SummaryPage{ | 51 | p := SummaryPage{ | ||
| 52 | + | Mirror: s.mirrorBadge(ctx, d.repo.Name), | |||
| 52 | AllHref: repoHref(d.name(), "commits", ref), | 53 | AllHref: repoHref(d.name(), "commits", ref), | ||
| 53 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | 54 | Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}}, | ||
| 54 | } | 55 | } | ||
M internal/adapters/web/view.go +1 -0
| @@ -203,6 +203,7 @@ type CommitRow struct { | |||||
| 203 | 203 | ||||
| 204 | // SummaryPage is the repository summary. | 204 | // SummaryPage is the repository summary. | ||
| 205 | type SummaryPage struct { | 205 | type SummaryPage struct { | ||
| 206 | + | Mirror *MirrorBadge | |||
| 206 | Facts []Fact | 207 | Facts []Fact | ||
| 207 | Clone Clone | 208 | Clone Clone | ||
| 208 | Recent []CommitRow | 209 | Recent []CommitRow | ||
M internal/adapters/web/web.go +3 -0
| @@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second | |||||
| 47 | 47 | ||||
| 48 | // Config configures a Server. | 48 | // Config configures a Server. | ||
| 49 | type Config struct { | 49 | type Config struct { | ||
| 50 | + | // Mirrors answers the push mirror badge shown to repository admins. Without it there is | |||
| 51 | + | // no badge. Leave it unset rather than assigning a typed nil pointer. | |||
| 52 | + | Mirrors ports.MirrorWarner | |||
| 50 | // Repos and Browse are the use cases the pages are built from. | 53 | // Repos and Browse are the use cases the pages are built from. | ||
| 51 | Repos ports.RepoService | 54 | Repos ports.RepoService | ||
| 52 | Browse ports.BrowseService | 55 | Browse ports.BrowseService | ||
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 196 | for _, who := range allPrincipals { | 196 | for _, who := range allPrincipals { | ||
| 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 198 | require.NoError(t, err, who) | 198 | 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) |
| 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 200 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 201 | } | 201 | } | ||
| 202 | }) | 202 | }) | ||
M internal/app/cli_e2e_test.go +1 -1
| @@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 350 | ver := decode[versionOut](t, res.stdout) | 350 | ver := decode[versionOut](t, res.stdout) | ||
| 351 | assert.Equal(t, c.url, ver.Server.URL) | 351 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 352 | assert.NotEmpty(t, ver.Server.Version) | 352 | 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) |
| 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | ||
| 355 | stdout, stderr, code = c.bin(t, secret, "version") | 355 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 356 | require.Equal(t, 0, code, stderr) | 356 | 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 { | |||||
| 31 | Comment *jsonComment `json:"comment,omitempty"` | 31 | Comment *jsonComment `json:"comment,omitempty"` | ||
| 32 | // Mergeability is set for a merge_request_mergeability event. | 32 | // Mergeability is set for a merge_request_mergeability event. | ||
| 33 | Mergeability *jsonMergeability `json:"mergeability,omitempty"` | 33 | Mergeability *jsonMergeability `json:"mergeability,omitempty"` | ||
| 34 | + | // State and FailureClass are set for a mirror_failed event. | |||
| 35 | + | State string `json:"state,omitempty"` | |||
| 36 | + | FailureClass string `json:"failure_class,omitempty"` | |||
| 34 | } | 37 | } | ||
| 35 | 38 | ||||
| 36 | type jsonRefUpdate struct { | 39 | type jsonRefUpdate struct { | ||
| @@ -55,6 +58,8 @@ const ( | |||||
| 55 | eventCommentDeleted = "comment_deleted" | 58 | eventCommentDeleted = "comment_deleted" | ||
| 56 | eventCommentResolved = "comment_resolved" | 59 | eventCommentResolved = "comment_resolved" | ||
| 57 | eventMRMergeability = "merge_request_mergeability" | 60 | eventMRMergeability = "merge_request_mergeability" | ||
| 61 | + | eventMirrorFailed = "mirror_failed" | |||
| 62 | + | eventMirrorRecovered = "mirror_recovered" | |||
| 58 | ) | 63 | ) | ||
| 59 | 64 | ||||
| 60 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | 65 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | ||
| @@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||||
| 100 | case *ermite.Event_MergeRequestMergeability: | 105 | case *ermite.Event_MergeRequestMergeability: | ||
| 101 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | 106 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | ||
| 102 | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) | 107 | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) | ||
| 108 | + | case *ermite.Event_MirrorFailed: | |||
| 109 | + | out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo() | |||
| 110 | + | out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass() | |||
| 111 | + | case *ermite.Event_MirrorRecovered: | |||
| 112 | + | out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo() | |||
| 103 | case *ermite.Event_Gap: | 113 | case *ermite.Event_Gap: | ||
| 104 | out.Type = eventGap | 114 | out.Type = eventGap | ||
| 105 | default: | 115 | default: | ||
| @@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string { | |||||
| 134 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | 144 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | ||
| 135 | case eventMRMergeability: | 145 | case eventMRMergeability: | ||
| 136 | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) | 146 | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) | ||
| 147 | + | case eventMirrorFailed: | |||
| 148 | + | detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")" | |||
| 149 | + | case eventMirrorRecovered: | |||
| 150 | + | detail = e.Repo | |||
| 137 | case eventGap: | 151 | case eventGap: | ||
| 138 | detail = "events were lost, reload what you display" | 152 | detail = "events were lost, reload what you display" | ||
| 139 | } | 153 | } | ||
M internal/app/cli_watch_test.go +17 -0
| @@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) { | |||||
| 266 | assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`) | 266 | assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`) | ||
| 267 | } | 267 | } | ||
| 268 | } | 268 | } | ||
| 269 | + | ||||
| 270 | + | func TestMirrorWatchEvents(t *testing.T) { | |||
| 271 | + | failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "") | |||
| 272 | + | require.True(t, ok) | |||
| 273 | + | assert.Equal(t, "mirror_failed", failed.Type) | |||
| 274 | + | assert.Equal(t, "alpha", failed.Repo) | |||
| 275 | + | assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)") | |||
| 276 | + | raw, err := json.Marshal(failed) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | assert.Contains(t, string(raw), `"state":"paused"`) | |||
| 279 | + | assert.Contains(t, string(raw), `"failure_class":"diverged"`) | |||
| 280 | + | recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "") | |||
| 281 | + | require.True(t, ok) | |||
| 282 | + | assert.Equal(t, "mirror_recovered", recovered.Type) | |||
| 283 | + | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | |||
| 284 | + | assert.NotContains(t, eventLine(recovered), " \n") | |||
| 285 | + | } | |||
M internal/app/forge.go +33 -1
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | "bnema.dev/ermite/internal/adapters/githttp" | 26 | "bnema.dev/ermite/internal/adapters/githttp" | ||
| 27 | "bnema.dev/ermite/internal/adapters/githubapi" | 27 | "bnema.dev/ermite/internal/adapters/githubapi" | ||
| 28 | "bnema.dev/ermite/internal/adapters/githubraw" | 28 | "bnema.dev/ermite/internal/adapters/githubraw" | ||
| 29 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |||
| 29 | "bnema.dev/ermite/internal/adapters/gitremote" | 30 | "bnema.dev/ermite/internal/adapters/gitremote" | ||
| 30 | "bnema.dev/ermite/internal/adapters/httpserver" | 31 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 31 | "bnema.dev/ermite/internal/adapters/kvstore" | 32 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| @@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes | |||||
| 49 | 50 | ||||
| 50 | // options are the parts of the wiring that tests replace. | 51 | // options are the parts of the wiring that tests replace. | ||
| 51 | type options struct { | 52 | type options struct { | ||
| 53 | + | // pusher and mirrors override mirror transport/settings in local tests only. | |||
| 54 | + | pusher ports.MirrorPusher | |||
| 55 | + | mirrors func(*usecase.MirrorsConfig) | |||
| 52 | // hookExe is the executable the hook scripts exec; empty means this | 56 | // hookExe is the executable the hook scripts exec; empty means this | ||
| 53 | // process's own executable. | 57 | // process's own executable. | ||
| 54 | hookExe string | 58 | hookExe string | ||
| @@ -151,6 +155,8 @@ type forge struct { | |||||
| 151 | CI *usecase.CI | 155 | CI *usecase.CI | ||
| 152 | // Settings are the secrets, variables and environments of CI. | 156 | // Settings are the secrets, variables and environments of CI. | ||
| 153 | Settings *usecase.CISettings | 157 | Settings *usecase.CISettings | ||
| 158 | + | // Mirrors exposes Warning and Badge for the hook server and web composition. | |||
| 159 | + | Mirrors *usecase.Mirrors | |||
| 154 | // OIDC is the provider of ID tokens, disabled without a secrets key. | 160 | // OIDC is the provider of ID tokens, disabled without a secrets key. | ||
| 155 | OIDC *usecase.OIDC | 161 | OIDC *usecase.OIDC | ||
| 156 | API *rpc.API | 162 | API *rpc.API | ||
| @@ -274,6 +280,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 274 | } | 280 | } | ||
| 275 | sealer = box | 281 | sealer = box | ||
| 276 | } | 282 | } | ||
| 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 | + | ||||
| 277 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | 304 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | ||
| 278 | if err != nil { | 305 | if err != nil { | ||
| 279 | return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err) | 306 | 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 | |||||
| 342 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | 369 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | ||
| 343 | }) | 370 | }) | ||
| 344 | f.Mergeability.Start() | 371 | f.Mergeability.Start() | ||
| 345 | - | f.MergeRequests.WithVerifier(f.Signatures) | 372 | + | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) |
| 346 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | 373 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | ||
| 347 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | 374 | Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock, | ||
| 348 | }) | 375 | }) | ||
| @@ -400,6 +427,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 400 | } | 427 | } | ||
| 401 | f.API = rpc.New(rpc.Config{ | 428 | f.API = rpc.New(rpc.Config{ | ||
| 402 | Auth: f.Auth, | 429 | Auth: f.Auth, | ||
| 430 | + | Mirrors: f.Mirrors, | |||
| 403 | Repos: f.Repos, | 431 | Repos: f.Repos, | ||
| 404 | Tokens: f.Tokens, | 432 | Tokens: f.Tokens, | ||
| 405 | Browse: f.Browse, | 433 | Browse: f.Browse, | ||
| @@ -489,6 +517,7 @@ func (f *forge) Run(ctx context.Context) error { | |||||
| 489 | // outlives the HTTP server: requests still in flight while it drains are | 517 | // outlives the HTTP server: requests still in flight while it drains are | ||
| 490 | // pushes whose hooks need it. A failure of either stops both. | 518 | // pushes whose hooks need it. A failure of either stops both. | ||
| 491 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | 519 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | ||
| 520 | + | f.Mirrors.Start() | |||
| 492 | ctx, cancel := context.WithCancel(ctx) | 521 | ctx, cancel := context.WithCancel(ctx) | ||
| 493 | defer cancel() | 522 | defer cancel() | ||
| 494 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | 523 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | ||
| @@ -517,6 +546,9 @@ func (f *forge) Close(ctx context.Context) { | |||||
| 517 | if f.MergeRequests != nil { | 546 | if f.MergeRequests != nil { | ||
| 518 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | 547 | f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability | ||
| 519 | } | 548 | } | ||
| 549 | + | if f.Mirrors != nil { | |||
| 550 | + | f.Mirrors.Close(ctx) | |||
| 551 | + | } | |||
| 520 | if f.CI != nil { | 552 | if f.CI != nil { | ||
| 521 | f.CI.Close(ctx) | 553 | f.CI.Close(ctx) | ||
| 522 | } | 554 | } | ||
M internal/app/root.go +1 -1
| @@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 30 | } | 30 | } | ||
| 31 | root.AddCommand( | 31 | root.AddCommand( | ||
| 32 | newServeCmd(), newHookCmd(), | 32 | newServeCmd(), newHookCmd(), | ||
| 33 | - | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 33 | + | newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), |
| 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | ||
| 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | 35 | newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env), | ||
| 36 | ) | 36 | ) | ||
M internal/app/web.go +1 -0
| @@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han | |||||
| 58 | Runs: f.CI, | 58 | Runs: f.CI, | ||
| 59 | Artifacts: artifacts, | 59 | Artifacts: artifacts, | ||
| 60 | Events: f.EventLog, | 60 | Events: f.EventLog, | ||
| 61 | + | Mirrors: f.Mirrors, | |||
| 61 | Owner: cfg.Owner, | 62 | Owner: cfg.Owner, | ||
| 62 | PublicURL: cfg.PublicURL, | 63 | PublicURL: cfg.PublicURL, | ||
| 63 | SourceURL: cfg.SourceURL, | 64 | SourceURL: cfg.SourceURL, | ||
M internal/domain/event.go +22 -0
| @@ -304,3 +304,25 @@ type EventGap struct{} | |||||
| 304 | 304 | ||||
| 305 | // EventKind implements Event. | 305 | // EventKind implements Event. | ||
| 306 | func (EventGap) EventKind() string { return "gap" } | 306 | 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 { | |||||
| 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | ||
| 168 | case domain.RepoDeleted: | 168 | case domain.RepoDeleted: | ||
| 169 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | 169 | 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) | |||
| 170 | case domain.PushEvent: | 174 | case domain.PushEvent: | ||
| 171 | if ev.CreatedAt.IsZero() { | 175 | if ev.CreatedAt.IsZero() { | ||
| 172 | return bufferedEvent{} // repository unknown at publish time: nobody is told | 176 | 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 | |||||
| 517 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | 521 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | ||
| 518 | case domain.RepoUpdated: | 522 | case domain.RepoUpdated: | ||
| 519 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | 523 | 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) | |||
| 520 | case domain.PushEvent: | 528 | case domain.PushEvent: | ||
| 521 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | 529 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | ||
| 522 | case domain.MergeRequestOpened: | 530 | case domain.MergeRequestOpened: | ||
M internal/usecase/mergerequests.go +10 -0
| @@ -58,6 +58,7 @@ type MergeRequests struct { | |||||
| 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | ||
| 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | ||
| 60 | mergeab MergeabilityHook // nil: no mergeability | 60 | mergeab MergeabilityHook // nil: no mergeability | ||
| 61 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 61 | 62 | ||||
| 62 | mu sync.Mutex | 63 | mu sync.Mutex | ||
| 63 | locks map[domain.RepoName]chan struct{} | 64 | locks map[domain.RepoName]chan struct{} | ||
| @@ -116,6 +117,12 @@ func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests { | |||||
| 116 | } | 117 | } | ||
| 117 | } | 118 | } | ||
| 118 | 119 | ||||
| 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 | + | ||||
| 119 | // WithVerifier makes GetMergeRequest fill the signature state of the commits | 126 | // WithVerifier makes GetMergeRequest fill the signature state of the commits | ||
| 120 | // it lists (ADR 0030). | 127 | // it lists (ADR 0030). | ||
| 121 | func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | 128 | func (m *MergeRequests) WithVerifier(v ports.CommitVerifier) *MergeRequests { | ||
| @@ -1172,6 +1179,9 @@ func (m *MergeRequests) afterMerge(ctx context.Context, p domain.Principal, repo | |||||
| 1172 | m.bus.Publish(ctx, domain.PushEvent{ | 1179 | m.bus.Publish(ctx, domain.PushEvent{ | ||
| 1173 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | 1180 | Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates, | ||
| 1174 | }) | 1181 | }) | ||
| 1182 | + | if m.mirror != nil { | |||
| 1183 | + | m.mirror.OnPush(ctx, name, updates) // marks dirty only; never blocks | |||
| 1184 | + | } | |||
| 1175 | if m.pulls != nil { | 1185 | if m.pulls != nil { | ||
| 1176 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | 1186 | m.pulls.OnPullRequest(ctx, name, ports.PullRequestEvent{Number: mr.Number, Action: domain.PullClosed, Head: mr.HeadID, Base: mr.BaseID, Merged: true}, p) | ||
| 1177 | } | 1187 | } | ||
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 ( | |||||
| 17 | 17 | ||||
| 18 | // Pushes are the use cases around a Git push. | 18 | // Pushes are the use cases around a Git push. | ||
| 19 | type Pushes struct { | 19 | type Pushes struct { | ||
| 20 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 20 | meta ports.RepoMetaStore | 21 | meta ports.RepoMetaStore | ||
| 21 | repos *Repos | 22 | repos *Repos | ||
| 22 | git ports.GitRepos | 23 | git ports.GitRepos | ||
| @@ -43,6 +44,12 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||||
| 43 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | 44 | return &Pushes{meta: meta, repos: repos, git: git, bus: bus, clock: clock, maint: maint, auth: auth} | ||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 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 | + | ||||
| 46 | // WithFreshener makes Record tell the reader that a repository changed, so that | 53 | // WithFreshener makes Record tell the reader that a repository changed, so that | ||
| 47 | // it serves the push at once. Call it before the first push; it returns u. | 54 | // it serves the push at once. Call it before the first push; it returns u. | ||
| 48 | func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes { | 55 | 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 | |||||
| 262 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | 269 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | ||
| 263 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | 270 | Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, | ||
| 264 | }) | 271 | }) | ||
| 272 | + | if u.mirror != nil { | |||
| 273 | + | u.mirror.OnPush(ctx, name, updates) // marks only: never blocks | |||
| 274 | + | } | |||
| 265 | u.maint.Schedule(ctx, name) | 275 | u.maint.Schedule(ctx, name) | ||
| 266 | log.Info(). | 276 | log.Info(). | ||
| 267 | Str("repo", name.String()).Int("refs", len(updates)). | 277 | Str("repo", name.String()).Int("refs", len(updates)). | ||
M internal/usecase/repos.go +13 -5
| @@ -17,11 +17,12 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | // Repos are the repository use cases. | 18 | // Repos are the repository use cases. | ||
| 19 | type Repos struct { | 19 | type Repos struct { | ||
| 20 | - | meta ports.RepoMetaStore | 20 | + | meta ports.RepoMetaStore |
| 21 | - | git ports.GitRepos | 21 | + | git ports.GitRepos |
| 22 | - | bus ports.EventBus | 22 | + | bus ports.EventBus |
| 23 | - | clock ports.Clock | 23 | + | clock ports.Clock |
| 24 | - | ci ports.CIRunCleaner // nil: no CI logs to remove | 24 | + | ci ports.CIRunCleaner // nil: no CI logs to remove |
| 25 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |||
| 25 | } | 26 | } | ||
| 26 | 27 | ||||
| 27 | var _ ports.RepoService = (*Repos)(nil) | 28 | var _ ports.RepoService = (*Repos)(nil) | ||
| @@ -32,6 +33,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, | |||||
| 32 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | 33 | return &Repos{meta: meta, git: git, bus: bus, clock: clock} | ||
| 33 | } | 34 | } | ||
| 34 | 35 | ||||
| 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 | + | ||||
| 35 | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | 43 | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | ||
| 36 | // before the first request; it returns r. | 44 | // before the first request; it returns r. | ||
| 37 | func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos { | 45 | 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 | |||||
| 129 | if !metaDone { | 129 | if !metaDone { | ||
| 130 | return domain.Repo{}, headErr | 130 | return domain.Repo{}, headErr | ||
| 131 | } | 131 | } | ||
| 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"}}) | |||
| 132 | } | 135 | } | ||
| 133 | } | 136 | } | ||
| 134 | 137 | ||||
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 | + | } | |||