Merge 0c0449b57b37c6b0468e726f2b3632d9d2879de8 into 55fcc8fd17ec2d8214aafd64e606433db222abb7
69 files · +8550 -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 | 132 | - [Configuration](docs/configuration.md): environment variables | |
| 133 | 133 | - [CLI](docs/cli.md): config file, commands, JSON output | |
| 134 | 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 | 136 | - [Tokens](docs/tokens.md): scopes, presets, branch-limited tokens | |
| 136 | 137 | - [Merge requests](docs/merge-requests.md) | |
| 137 | 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 | 62 | - Start on a push that creates or deletes a branch or tag (also a merge with `--delete-branch`). | |
| 63 | 63 | - No filters; `[skip ci]` does not apply. A push of more than three tags starts none. | |
| 64 | 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 | 75 | ermite variable set|get|list|delete … | |
| 76 | 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 | 83 | ermite web login # one-time sign-in link (60 s) | |
| 79 | 84 | ermite web logout --all # sign every browser out | |
| 80 | 85 | ermite web passkey list | remove <id> | |
| @@ -87,9 +92,11 @@ ermite completion bash|zsh|fish | |||
| 87 | 92 | ||
| 88 | 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 | 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 | 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 | 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 | 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
| @@ -0,0 +1,577 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Code generated by protoc-gen-go. DO NOT EDIT. | |
| 4 | + | // versions: | |
| 5 | + | // protoc-gen-go v1.36.12 | |
| 6 | + | // protoc (unknown) | |
| 7 | + | // source: ermite/storage/mirror.proto | |
| 8 | + | ||
| 9 | + | package storagepb | |
| 10 | + | ||
| 11 | + | import ( | |
| 12 | + | protoreflect "google.golang.org/protobuf/reflect/protoreflect" | |
| 13 | + | protoimpl "google.golang.org/protobuf/runtime/protoimpl" | |
| 14 | + | timestamppb "google.golang.org/protobuf/types/known/timestamppb" | |
| 15 | + | reflect "reflect" | |
| 16 | + | sync "sync" | |
| 17 | + | unsafe "unsafe" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | // Verify that this generated code is sufficiently up-to-date. | |
| 22 | + | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) | |
| 23 | + | // Verify that runtime/protoimpl is sufficiently up-to-date. | |
| 24 | + | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | type MirrorKey struct { | |
| 28 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 29 | + | Sealed []byte `protobuf:"bytes,1,opt,name=sealed,proto3" json:"sealed,omitempty"` | |
| 30 | + | CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` | |
| 31 | + | unknownFields protoimpl.UnknownFields | |
| 32 | + | sizeCache protoimpl.SizeCache | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (x *MirrorKey) Reset() { | |
| 36 | + | *x = MirrorKey{} | |
| 37 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |
| 38 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 39 | + | ms.StoreMessageInfo(mi) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (x *MirrorKey) String() string { | |
| 43 | + | return protoimpl.X.MessageStringOf(x) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (*MirrorKey) ProtoMessage() {} | |
| 47 | + | ||
| 48 | + | func (x *MirrorKey) ProtoReflect() protoreflect.Message { | |
| 49 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[0] | |
| 50 | + | if x != nil { | |
| 51 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 52 | + | if ms.LoadMessageInfo() == nil { | |
| 53 | + | ms.StoreMessageInfo(mi) | |
| 54 | + | } | |
| 55 | + | return ms | |
| 56 | + | } | |
| 57 | + | return mi.MessageOf(x) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Deprecated: Use MirrorKey.ProtoReflect.Descriptor instead. | |
| 61 | + | func (*MirrorKey) Descriptor() ([]byte, []int) { | |
| 62 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{0} | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (x *MirrorKey) GetSealed() []byte { | |
| 66 | + | if x != nil { | |
| 67 | + | return x.Sealed | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (x *MirrorKey) GetCreatedAt() *timestamppb.Timestamp { | |
| 73 | + | if x != nil { | |
| 74 | + | return x.CreatedAt | |
| 75 | + | } | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | type MirrorGlobal struct { | |
| 80 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 81 | + | Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` | |
| 82 | + | UrlTemplate string `protobuf:"bytes,2,opt,name=url_template,json=urlTemplate,proto3" json:"url_template,omitempty"` | |
| 83 | + | Cron string `protobuf:"bytes,3,opt,name=cron,proto3" json:"cron,omitempty"` | |
| 84 | + | AllBranches bool `protobuf:"varint,4,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |
| 85 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 86 | + | unknownFields protoimpl.UnknownFields | |
| 87 | + | sizeCache protoimpl.SizeCache | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (x *MirrorGlobal) Reset() { | |
| 91 | + | *x = MirrorGlobal{} | |
| 92 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |
| 93 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 94 | + | ms.StoreMessageInfo(mi) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (x *MirrorGlobal) String() string { | |
| 98 | + | return protoimpl.X.MessageStringOf(x) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (*MirrorGlobal) ProtoMessage() {} | |
| 102 | + | ||
| 103 | + | func (x *MirrorGlobal) ProtoReflect() protoreflect.Message { | |
| 104 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[1] | |
| 105 | + | if x != nil { | |
| 106 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 107 | + | if ms.LoadMessageInfo() == nil { | |
| 108 | + | ms.StoreMessageInfo(mi) | |
| 109 | + | } | |
| 110 | + | return ms | |
| 111 | + | } | |
| 112 | + | return mi.MessageOf(x) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // Deprecated: Use MirrorGlobal.ProtoReflect.Descriptor instead. | |
| 116 | + | func (*MirrorGlobal) Descriptor() ([]byte, []int) { | |
| 117 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{1} | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (x *MirrorGlobal) GetEnabled() bool { | |
| 121 | + | if x != nil { | |
| 122 | + | return x.Enabled | |
| 123 | + | } | |
| 124 | + | return false | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (x *MirrorGlobal) GetUrlTemplate() string { | |
| 128 | + | if x != nil { | |
| 129 | + | return x.UrlTemplate | |
| 130 | + | } | |
| 131 | + | return "" | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (x *MirrorGlobal) GetCron() string { | |
| 135 | + | if x != nil { | |
| 136 | + | return x.Cron | |
| 137 | + | } | |
| 138 | + | return "" | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (x *MirrorGlobal) GetAllBranches() bool { | |
| 142 | + | if x != nil { | |
| 143 | + | return x.AllBranches | |
| 144 | + | } | |
| 145 | + | return false | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (x *MirrorGlobal) GetUpdatedAt() *timestamppb.Timestamp { | |
| 149 | + | if x != nil { | |
| 150 | + | return x.UpdatedAt | |
| 151 | + | } | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | type MirrorRepo struct { | |
| 156 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 157 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |
| 158 | + | Cron string `protobuf:"bytes,2,opt,name=cron,proto3" json:"cron,omitempty"` | |
| 159 | + | AllBranches *bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3,oneof" json:"all_branches,omitempty"` | |
| 160 | + | Excluded bool `protobuf:"varint,4,opt,name=excluded,proto3" json:"excluded,omitempty"` | |
| 161 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 162 | + | unknownFields protoimpl.UnknownFields | |
| 163 | + | sizeCache protoimpl.SizeCache | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func (x *MirrorRepo) Reset() { | |
| 167 | + | *x = MirrorRepo{} | |
| 168 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |
| 169 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 170 | + | ms.StoreMessageInfo(mi) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func (x *MirrorRepo) String() string { | |
| 174 | + | return protoimpl.X.MessageStringOf(x) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (*MirrorRepo) ProtoMessage() {} | |
| 178 | + | ||
| 179 | + | func (x *MirrorRepo) ProtoReflect() protoreflect.Message { | |
| 180 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[2] | |
| 181 | + | if x != nil { | |
| 182 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 183 | + | if ms.LoadMessageInfo() == nil { | |
| 184 | + | ms.StoreMessageInfo(mi) | |
| 185 | + | } | |
| 186 | + | return ms | |
| 187 | + | } | |
| 188 | + | return mi.MessageOf(x) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // Deprecated: Use MirrorRepo.ProtoReflect.Descriptor instead. | |
| 192 | + | func (*MirrorRepo) Descriptor() ([]byte, []int) { | |
| 193 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{2} | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (x *MirrorRepo) GetUrl() string { | |
| 197 | + | if x != nil { | |
| 198 | + | return x.Url | |
| 199 | + | } | |
| 200 | + | return "" | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (x *MirrorRepo) GetCron() string { | |
| 204 | + | if x != nil { | |
| 205 | + | return x.Cron | |
| 206 | + | } | |
| 207 | + | return "" | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (x *MirrorRepo) GetAllBranches() bool { | |
| 211 | + | if x != nil && x.AllBranches != nil { | |
| 212 | + | return *x.AllBranches | |
| 213 | + | } | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (x *MirrorRepo) GetExcluded() bool { | |
| 218 | + | if x != nil { | |
| 219 | + | return x.Excluded | |
| 220 | + | } | |
| 221 | + | return false | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (x *MirrorRepo) GetUpdatedAt() *timestamppb.Timestamp { | |
| 225 | + | if x != nil { | |
| 226 | + | return x.UpdatedAt | |
| 227 | + | } | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | type MirrorState struct { | |
| 232 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 233 | + | State string `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` | |
| 234 | + | Class string `protobuf:"bytes,2,opt,name=class,proto3" json:"class,omitempty"` | |
| 235 | + | Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` | |
| 236 | + | Detail string `protobuf:"bytes,4,opt,name=detail,proto3" json:"detail,omitempty"` | |
| 237 | + | ConsecutiveFailures int32 `protobuf:"varint,5,opt,name=consecutive_failures,json=consecutiveFailures,proto3" json:"consecutive_failures,omitempty"` | |
| 238 | + | LastAttemptAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_attempt_at,json=lastAttemptAt,proto3" json:"last_attempt_at,omitempty"` | |
| 239 | + | LastSuccessAt *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=last_success_at,json=lastSuccessAt,proto3" json:"last_success_at,omitempty"` | |
| 240 | + | NextRetryAt *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=next_retry_at,json=nextRetryAt,proto3" json:"next_retry_at,omitempty"` | |
| 241 | + | PausedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=paused_at,json=pausedAt,proto3" json:"paused_at,omitempty"` | |
| 242 | + | UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` | |
| 243 | + | unknownFields protoimpl.UnknownFields | |
| 244 | + | sizeCache protoimpl.SizeCache | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func (x *MirrorState) Reset() { | |
| 248 | + | *x = MirrorState{} | |
| 249 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |
| 250 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 251 | + | ms.StoreMessageInfo(mi) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (x *MirrorState) String() string { | |
| 255 | + | return protoimpl.X.MessageStringOf(x) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (*MirrorState) ProtoMessage() {} | |
| 259 | + | ||
| 260 | + | func (x *MirrorState) ProtoReflect() protoreflect.Message { | |
| 261 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[3] | |
| 262 | + | if x != nil { | |
| 263 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 264 | + | if ms.LoadMessageInfo() == nil { | |
| 265 | + | ms.StoreMessageInfo(mi) | |
| 266 | + | } | |
| 267 | + | return ms | |
| 268 | + | } | |
| 269 | + | return mi.MessageOf(x) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Deprecated: Use MirrorState.ProtoReflect.Descriptor instead. | |
| 273 | + | func (*MirrorState) Descriptor() ([]byte, []int) { | |
| 274 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{3} | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func (x *MirrorState) GetState() string { | |
| 278 | + | if x != nil { | |
| 279 | + | return x.State | |
| 280 | + | } | |
| 281 | + | return "" | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (x *MirrorState) GetClass() string { | |
| 285 | + | if x != nil { | |
| 286 | + | return x.Class | |
| 287 | + | } | |
| 288 | + | return "" | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (x *MirrorState) GetReason() string { | |
| 292 | + | if x != nil { | |
| 293 | + | return x.Reason | |
| 294 | + | } | |
| 295 | + | return "" | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (x *MirrorState) GetDetail() string { | |
| 299 | + | if x != nil { | |
| 300 | + | return x.Detail | |
| 301 | + | } | |
| 302 | + | return "" | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | func (x *MirrorState) GetConsecutiveFailures() int32 { | |
| 306 | + | if x != nil { | |
| 307 | + | return x.ConsecutiveFailures | |
| 308 | + | } | |
| 309 | + | return 0 | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func (x *MirrorState) GetLastAttemptAt() *timestamppb.Timestamp { | |
| 313 | + | if x != nil { | |
| 314 | + | return x.LastAttemptAt | |
| 315 | + | } | |
| 316 | + | return nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func (x *MirrorState) GetLastSuccessAt() *timestamppb.Timestamp { | |
| 320 | + | if x != nil { | |
| 321 | + | return x.LastSuccessAt | |
| 322 | + | } | |
| 323 | + | return nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (x *MirrorState) GetNextRetryAt() *timestamppb.Timestamp { | |
| 327 | + | if x != nil { | |
| 328 | + | return x.NextRetryAt | |
| 329 | + | } | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func (x *MirrorState) GetPausedAt() *timestamppb.Timestamp { | |
| 334 | + | if x != nil { | |
| 335 | + | return x.PausedAt | |
| 336 | + | } | |
| 337 | + | return nil | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func (x *MirrorState) GetUpdatedAt() *timestamppb.Timestamp { | |
| 341 | + | if x != nil { | |
| 342 | + | return x.UpdatedAt | |
| 343 | + | } | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | type MirrorSnapshotRef struct { | |
| 348 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 349 | + | Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` | |
| 350 | + | Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` | |
| 351 | + | unknownFields protoimpl.UnknownFields | |
| 352 | + | sizeCache protoimpl.SizeCache | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func (x *MirrorSnapshotRef) Reset() { | |
| 356 | + | *x = MirrorSnapshotRef{} | |
| 357 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |
| 358 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 359 | + | ms.StoreMessageInfo(mi) | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | func (x *MirrorSnapshotRef) String() string { | |
| 363 | + | return protoimpl.X.MessageStringOf(x) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func (*MirrorSnapshotRef) ProtoMessage() {} | |
| 367 | + | ||
| 368 | + | func (x *MirrorSnapshotRef) ProtoReflect() protoreflect.Message { | |
| 369 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[4] | |
| 370 | + | if x != nil { | |
| 371 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 372 | + | if ms.LoadMessageInfo() == nil { | |
| 373 | + | ms.StoreMessageInfo(mi) | |
| 374 | + | } | |
| 375 | + | return ms | |
| 376 | + | } | |
| 377 | + | return mi.MessageOf(x) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | // Deprecated: Use MirrorSnapshotRef.ProtoReflect.Descriptor instead. | |
| 381 | + | func (*MirrorSnapshotRef) Descriptor() ([]byte, []int) { | |
| 382 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{4} | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | func (x *MirrorSnapshotRef) GetName() string { | |
| 386 | + | if x != nil { | |
| 387 | + | return x.Name | |
| 388 | + | } | |
| 389 | + | return "" | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | func (x *MirrorSnapshotRef) GetId() string { | |
| 393 | + | if x != nil { | |
| 394 | + | return x.Id | |
| 395 | + | } | |
| 396 | + | return "" | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | type MirrorSnapshot struct { | |
| 400 | + | state protoimpl.MessageState `protogen:"open.v1"` | |
| 401 | + | Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` | |
| 402 | + | Refs []*MirrorSnapshotRef `protobuf:"bytes,2,rep,name=refs,proto3" json:"refs,omitempty"` | |
| 403 | + | AllBranches bool `protobuf:"varint,3,opt,name=all_branches,json=allBranches,proto3" json:"all_branches,omitempty"` | |
| 404 | + | At *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=at,proto3" json:"at,omitempty"` | |
| 405 | + | unknownFields protoimpl.UnknownFields | |
| 406 | + | sizeCache protoimpl.SizeCache | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func (x *MirrorSnapshot) Reset() { | |
| 410 | + | *x = MirrorSnapshot{} | |
| 411 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |
| 412 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 413 | + | ms.StoreMessageInfo(mi) | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | func (x *MirrorSnapshot) String() string { | |
| 417 | + | return protoimpl.X.MessageStringOf(x) | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | func (*MirrorSnapshot) ProtoMessage() {} | |
| 421 | + | ||
| 422 | + | func (x *MirrorSnapshot) ProtoReflect() protoreflect.Message { | |
| 423 | + | mi := &file_ermite_storage_mirror_proto_msgTypes[5] | |
| 424 | + | if x != nil { | |
| 425 | + | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) | |
| 426 | + | if ms.LoadMessageInfo() == nil { | |
| 427 | + | ms.StoreMessageInfo(mi) | |
| 428 | + | } | |
| 429 | + | return ms | |
| 430 | + | } | |
| 431 | + | return mi.MessageOf(x) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // Deprecated: Use MirrorSnapshot.ProtoReflect.Descriptor instead. | |
| 435 | + | func (*MirrorSnapshot) Descriptor() ([]byte, []int) { | |
| 436 | + | return file_ermite_storage_mirror_proto_rawDescGZIP(), []int{5} | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | func (x *MirrorSnapshot) GetUrl() string { | |
| 440 | + | if x != nil { | |
| 441 | + | return x.Url | |
| 442 | + | } | |
| 443 | + | return "" | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (x *MirrorSnapshot) GetRefs() []*MirrorSnapshotRef { | |
| 447 | + | if x != nil { | |
| 448 | + | return x.Refs | |
| 449 | + | } | |
| 450 | + | return nil | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func (x *MirrorSnapshot) GetAllBranches() bool { | |
| 454 | + | if x != nil { | |
| 455 | + | return x.AllBranches | |
| 456 | + | } | |
| 457 | + | return false | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | func (x *MirrorSnapshot) GetAt() *timestamppb.Timestamp { | |
| 461 | + | if x != nil { | |
| 462 | + | return x.At | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | var File_ermite_storage_mirror_proto protoreflect.FileDescriptor | |
| 468 | + | ||
| 469 | + | const file_ermite_storage_mirror_proto_rawDesc = "" + | |
| 470 | + | "\n" + | |
| 471 | + | "\x1bermite/storage/mirror.proto\x12\x0eermite.storage\x1a\x1fgoogle/protobuf/timestamp.proto\"^\n" + | |
| 472 | + | "\tMirrorKey\x12\x16\n" + | |
| 473 | + | "\x06sealed\x18\x01 \x01(\fR\x06sealed\x129\n" + | |
| 474 | + | "\n" + | |
| 475 | + | "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\"\xbd\x01\n" + | |
| 476 | + | "\fMirrorGlobal\x12\x18\n" + | |
| 477 | + | "\aenabled\x18\x01 \x01(\bR\aenabled\x12!\n" + | |
| 478 | + | "\furl_template\x18\x02 \x01(\tR\vurlTemplate\x12\x12\n" + | |
| 479 | + | "\x04cron\x18\x03 \x01(\tR\x04cron\x12!\n" + | |
| 480 | + | "\fall_branches\x18\x04 \x01(\bR\vallBranches\x129\n" + | |
| 481 | + | "\n" + | |
| 482 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"\xc2\x01\n" + | |
| 483 | + | "\n" + | |
| 484 | + | "MirrorRepo\x12\x10\n" + | |
| 485 | + | "\x03url\x18\x01 \x01(\tR\x03url\x12\x12\n" + | |
| 486 | + | "\x04cron\x18\x02 \x01(\tR\x04cron\x12&\n" + | |
| 487 | + | "\fall_branches\x18\x03 \x01(\bH\x00R\vallBranches\x88\x01\x01\x12\x1a\n" + | |
| 488 | + | "\bexcluded\x18\x04 \x01(\bR\bexcluded\x129\n" + | |
| 489 | + | "\n" + | |
| 490 | + | "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAtB\x0f\n" + | |
| 491 | + | "\r_all_branches\"\xd8\x03\n" + | |
| 492 | + | "\vMirrorState\x12\x14\n" + | |
| 493 | + | "\x05state\x18\x01 \x01(\tR\x05state\x12\x14\n" + | |
| 494 | + | "\x05class\x18\x02 \x01(\tR\x05class\x12\x16\n" + | |
| 495 | + | "\x06reason\x18\x03 \x01(\tR\x06reason\x12\x16\n" + | |
| 496 | + | "\x06detail\x18\x04 \x01(\tR\x06detail\x121\n" + | |
| 497 | + | "\x14consecutive_failures\x18\x05 \x01(\x05R\x13consecutiveFailures\x12B\n" + | |
| 498 | + | "\x0flast_attempt_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\rlastAttemptAt\x12B\n" + | |
| 499 | + | "\x0flast_success_at\x18\a \x01(\v2\x1a.google.protobuf.TimestampR\rlastSuccessAt\x12>\n" + | |
| 500 | + | "\rnext_retry_at\x18\b \x01(\v2\x1a.google.protobuf.TimestampR\vnextRetryAt\x127\n" + | |
| 501 | + | "\tpaused_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\bpausedAt\x129\n" + | |
| 502 | + | "\n" + | |
| 503 | + | "updated_at\x18\n" + | |
| 504 | + | " \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"7\n" + | |
| 505 | + | "\x11MirrorSnapshotRef\x12\x12\n" + | |
| 506 | + | "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + | |
| 507 | + | "\x02id\x18\x02 \x01(\tR\x02id\"\xa8\x01\n" + | |
| 508 | + | "\x0eMirrorSnapshot\x12\x10\n" + | |
| 509 | + | "\x03url\x18\x01 \x01(\tR\x03url\x125\n" + | |
| 510 | + | "\x04refs\x18\x02 \x03(\v2!.ermite.storage.MirrorSnapshotRefR\x04refs\x12!\n" + | |
| 511 | + | "\fall_branches\x18\x03 \x01(\bR\vallBranches\x12*\n" + | |
| 512 | + | "\x02at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x02atB/Z-bnema.dev/ermite/gen/ermite/storage;storagepbb\x06proto3" | |
| 513 | + | ||
| 514 | + | var ( | |
| 515 | + | file_ermite_storage_mirror_proto_rawDescOnce sync.Once | |
| 516 | + | file_ermite_storage_mirror_proto_rawDescData []byte | |
| 517 | + | ) | |
| 518 | + | ||
| 519 | + | func file_ermite_storage_mirror_proto_rawDescGZIP() []byte { | |
| 520 | + | file_ermite_storage_mirror_proto_rawDescOnce.Do(func() { | |
| 521 | + | file_ermite_storage_mirror_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc))) | |
| 522 | + | }) | |
| 523 | + | return file_ermite_storage_mirror_proto_rawDescData | |
| 524 | + | } | |
| 525 | + | ||
| 526 | + | var file_ermite_storage_mirror_proto_msgTypes = make([]protoimpl.MessageInfo, 6) | |
| 527 | + | var file_ermite_storage_mirror_proto_goTypes = []any{ | |
| 528 | + | (*MirrorKey)(nil), // 0: ermite.storage.MirrorKey | |
| 529 | + | (*MirrorGlobal)(nil), // 1: ermite.storage.MirrorGlobal | |
| 530 | + | (*MirrorRepo)(nil), // 2: ermite.storage.MirrorRepo | |
| 531 | + | (*MirrorState)(nil), // 3: ermite.storage.MirrorState | |
| 532 | + | (*MirrorSnapshotRef)(nil), // 4: ermite.storage.MirrorSnapshotRef | |
| 533 | + | (*MirrorSnapshot)(nil), // 5: ermite.storage.MirrorSnapshot | |
| 534 | + | (*timestamppb.Timestamp)(nil), // 6: google.protobuf.Timestamp | |
| 535 | + | } | |
| 536 | + | var file_ermite_storage_mirror_proto_depIdxs = []int32{ | |
| 537 | + | 6, // 0: ermite.storage.MirrorKey.created_at:type_name -> google.protobuf.Timestamp | |
| 538 | + | 6, // 1: ermite.storage.MirrorGlobal.updated_at:type_name -> google.protobuf.Timestamp | |
| 539 | + | 6, // 2: ermite.storage.MirrorRepo.updated_at:type_name -> google.protobuf.Timestamp | |
| 540 | + | 6, // 3: ermite.storage.MirrorState.last_attempt_at:type_name -> google.protobuf.Timestamp | |
| 541 | + | 6, // 4: ermite.storage.MirrorState.last_success_at:type_name -> google.protobuf.Timestamp | |
| 542 | + | 6, // 5: ermite.storage.MirrorState.next_retry_at:type_name -> google.protobuf.Timestamp | |
| 543 | + | 6, // 6: ermite.storage.MirrorState.paused_at:type_name -> google.protobuf.Timestamp | |
| 544 | + | 6, // 7: ermite.storage.MirrorState.updated_at:type_name -> google.protobuf.Timestamp | |
| 545 | + | 4, // 8: ermite.storage.MirrorSnapshot.refs:type_name -> ermite.storage.MirrorSnapshotRef | |
| 546 | + | 6, // 9: ermite.storage.MirrorSnapshot.at:type_name -> google.protobuf.Timestamp | |
| 547 | + | 10, // [10:10] is the sub-list for method output_type | |
| 548 | + | 10, // [10:10] is the sub-list for method input_type | |
| 549 | + | 10, // [10:10] is the sub-list for extension type_name | |
| 550 | + | 10, // [10:10] is the sub-list for extension extendee | |
| 551 | + | 0, // [0:10] is the sub-list for field type_name | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | func init() { file_ermite_storage_mirror_proto_init() } | |
| 555 | + | func file_ermite_storage_mirror_proto_init() { | |
| 556 | + | if File_ermite_storage_mirror_proto != nil { | |
| 557 | + | return | |
| 558 | + | } | |
| 559 | + | file_ermite_storage_mirror_proto_msgTypes[2].OneofWrappers = []any{} | |
| 560 | + | type x struct{} | |
| 561 | + | out := protoimpl.TypeBuilder{ | |
| 562 | + | File: protoimpl.DescBuilder{ | |
| 563 | + | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), | |
| 564 | + | RawDescriptor: unsafe.Slice(unsafe.StringData(file_ermite_storage_mirror_proto_rawDesc), len(file_ermite_storage_mirror_proto_rawDesc)), | |
| 565 | + | NumEnums: 0, | |
| 566 | + | NumMessages: 6, | |
| 567 | + | NumExtensions: 0, | |
| 568 | + | NumServices: 0, | |
| 569 | + | }, | |
| 570 | + | GoTypes: file_ermite_storage_mirror_proto_goTypes, | |
| 571 | + | DependencyIndexes: file_ermite_storage_mirror_proto_depIdxs, | |
| 572 | + | MessageInfos: file_ermite_storage_mirror_proto_msgTypes, | |
| 573 | + | }.Build() | |
| 574 | + | File_ermite_storage_mirror_proto = out.File | |
| 575 | + | file_ermite_storage_mirror_proto_goTypes = nil | |
| 576 | + | file_ermite_storage_mirror_proto_depIdxs = nil | |
| 577 | + | } | |
M go.mod +1 -1
| @@ -13,7 +13,7 @@ tool ( | |||
| 13 | 13 | ||
| 14 | 14 | require ( | |
| 15 | 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 | 17 | bnema.dev/ermite-git v0.1.0 | |
| 18 | 18 | bnema.dev/ermite-kv v0.1.0 | |
| 19 | 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 | 60 | // URL is the server address the client talks to. | |
| 61 | 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 | 68 | // Repos returns the RepoService client. | |
| 64 | 69 | func (c *Client) Repos() ermiteconnect.RepoServiceClient { | |
| 65 | 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 | 37 | // HookServer registers the per-request hook tokens and serves the Unix | |
| 38 | 38 | // socket the hooks call back on. It implements HookTokens. | |
| 39 | 39 | type HookServer struct { | |
| 40 | - | path string | |
| 41 | - | hooks ports.PushHookService | |
| 40 | + | path string | |
| 41 | + | hooks ports.PushHookService | |
| 42 | + | mirrors ports.MirrorWarner | |
| 42 | 43 | ||
| 43 | 44 | mu sync.RWMutex | |
| 44 | 45 | tokens map[string]hookEntry | |
| @@ -56,6 +57,32 @@ func NewHookServer(path string, hooks ports.PushHookService) *HookServer { | |||
| 56 | 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 | 86 | // Socket returns the path of the socket. | |
| 60 | 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 | 256 | s.reply(ctx, conn, HookReply{OK: true, Message: "ermite: the push succeeded but recording it failed"}) | |
| 230 | 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 | 412 | require.NoError(t, err) | |
| 413 | 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 +250 -0
| @@ -0,0 +1,250 @@ | |||
| 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 | + | "golang.org/x/crypto/ssh" | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | type Config struct { | |
| 25 | + | Bin, Path, Home, KnownHosts, TempDir string | |
| 26 | + | Timeout time.Duration | |
| 27 | + | MaxOutput int | |
| 28 | + | AllowedProtocols string | |
| 29 | + | SSHOptions []string | |
| 30 | + | RepoDir func(domain.RepoName) string | |
| 31 | + | } | |
| 32 | + | type Pusher struct{ cfg Config } | |
| 33 | + | ||
| 34 | + | var _ ports.MirrorPusher = (*Pusher)(nil) | |
| 35 | + | ||
| 36 | + | func New(cfg Config) *Pusher { | |
| 37 | + | if cfg.Timeout <= 0 { | |
| 38 | + | cfg.Timeout = 10 * time.Minute | |
| 39 | + | } | |
| 40 | + | if cfg.MaxOutput <= 0 { | |
| 41 | + | cfg.MaxOutput = 1 << 20 | |
| 42 | + | } | |
| 43 | + | if cfg.AllowedProtocols == "" { | |
| 44 | + | cfg.AllowedProtocols = "ssh" | |
| 45 | + | } | |
| 46 | + | cfg.SSHOptions = append([]string(nil), cfg.SSHOptions...) | |
| 47 | + | return &Pusher{cfg: cfg} | |
| 48 | + | } | |
| 49 | + | func quote(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } | |
| 50 | + | ||
| 51 | + | // validRefspec deliberately accepts only explicit same-name heads and tags. | |
| 52 | + | // Empty sources, wildcards, force prefixes and revision expressions never reach git. | |
| 53 | + | func validRefspec(s string) bool { | |
| 54 | + | a, b, ok := strings.Cut(s, ":") | |
| 55 | + | if !ok || a != b || !(strings.HasPrefix(a, "refs/heads/") || strings.HasPrefix(a, "refs/tags/")) { | |
| 56 | + | return false | |
| 57 | + | } | |
| 58 | + | if strings.ContainsAny(a, "+*?[:\\^~ ") || strings.Contains(a, "..") || strings.Contains(a, "@{") || strings.Contains(a, "//") || strings.HasSuffix(a, "/") || strings.HasSuffix(a, ".") { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | for _, part := range strings.Split(a, "/") { | |
| 62 | + | if part == "" || strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") { | |
| 63 | + | return false | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return !strings.ContainsFunc(a, unicode.IsControl) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (p *Pusher) Push(parent context.Context, req ports.MirrorPush) (result ports.MirrorPushResult, err error) { | |
| 70 | + | start := time.Now() | |
| 71 | + | defer func() { result.Duration = time.Since(start) }() | |
| 72 | + | internal := func(detail string) error { | |
| 73 | + | return &domain.MirrorError{Class: domain.MirrorClassInternal, Detail: detail} | |
| 74 | + | } | |
| 75 | + | if err := parent.Err(); err != nil { | |
| 76 | + | return result, err | |
| 77 | + | } | |
| 78 | + | if len(req.PrivateKey) != ed25519.PrivateKeySize { | |
| 79 | + | return result, internal("invalid mirror private key") | |
| 80 | + | } | |
| 81 | + | if p.cfg.RepoDir == nil || p.cfg.TempDir == "" || p.cfg.Home == "" || p.cfg.KnownHosts == "" { | |
| 82 | + | return result, internal("mirror transport directories are not configured") | |
| 83 | + | } | |
| 84 | + | if req.URL == "" || strings.HasPrefix(req.URL, "-") || strings.ContainsFunc(req.URL, unicode.IsControl) { | |
| 85 | + | return result, internal("invalid mirror target") | |
| 86 | + | } | |
| 87 | + | for _, ref := range req.Refspecs { | |
| 88 | + | if !validRefspec(ref) { | |
| 89 | + | return result, internal("invalid mirror refspec") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if len(req.Refspecs) == 0 { | |
| 93 | + | return result, nil | |
| 94 | + | } // never let git select implicit refspecs | |
| 95 | + | ctx, cancel := context.WithTimeout(parent, p.cfg.Timeout) | |
| 96 | + | defer cancel() | |
| 97 | + | if err := os.MkdirAll(p.cfg.TempDir, 0700); err != nil { | |
| 98 | + | return result, internal("create mirror key directory") | |
| 99 | + | } | |
| 100 | + | if err := os.Chmod(p.cfg.TempDir, 0700); err != nil { | |
| 101 | + | return result, internal("protect mirror key directory") | |
| 102 | + | } | |
| 103 | + | dir, e := os.MkdirTemp(p.cfg.TempDir, "push-") | |
| 104 | + | if e != nil { | |
| 105 | + | return result, internal("create temporary mirror directory") | |
| 106 | + | } | |
| 107 | + | defer os.RemoveAll(dir) | |
| 108 | + | block, e := ssh.MarshalPrivateKey(req.PrivateKey, "") | |
| 109 | + | if e != nil { | |
| 110 | + | return result, internal("encode mirror private key") | |
| 111 | + | } | |
| 112 | + | f, e := os.CreateTemp(dir, "key-") | |
| 113 | + | if e != nil { | |
| 114 | + | return result, internal("create temporary mirror key") | |
| 115 | + | } | |
| 116 | + | keyPath := f.Name() | |
| 117 | + | _, e = f.Write(pem.EncodeToMemory(block)) | |
| 118 | + | closeErr := f.Close() | |
| 119 | + | if e != nil || closeErr != nil { | |
| 120 | + | return result, internal("write temporary mirror key") | |
| 121 | + | } | |
| 122 | + | // -F /dev/null prevents ~/.ssh/config and system config from adding credentials | |
| 123 | + | // or weakening the required options. Required options precede caller extras. | |
| 124 | + | command := "ssh -F /dev/null -i " + quote(keyPath) | |
| 125 | + | for _, opt := range append([]string{"IdentitiesOnly=yes", "BatchMode=yes", "StrictHostKeyChecking=accept-new", "UserKnownHostsFile=" + p.cfg.KnownHosts, "ConnectTimeout=30", "ServerAliveInterval=15", "ServerAliveCountMax=3"}, p.cfg.SSHOptions...) { | |
| 126 | + | command += " -o " + quote(opt) | |
| 127 | + | } | |
| 128 | + | cleanup := max(min(2*time.Second, p.cfg.Timeout/8), time.Millisecond) | |
| 129 | + | runCtx, stop := context.WithTimeout(ctx, max(p.cfg.Timeout-cleanup, time.Millisecond)) | |
| 130 | + | defer stop() | |
| 131 | + | stdout := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |
| 132 | + | stderr := &limitWriter{max: p.cfg.MaxOutput, cancel: stop} | |
| 133 | + | args := []string{"-C", p.cfg.RepoDir(req.Repo), "push", "--porcelain", "--no-verify", "--atomic", "--", req.URL} | |
| 134 | + | args = append(args, req.Refspecs...) | |
| 135 | + | cmd := exec.Command(p.cfg.Bin, args...) | |
| 136 | + | 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"} | |
| 137 | + | cmd.Dir = p.cfg.Home | |
| 138 | + | cmd.Stdout = stdout | |
| 139 | + | cmd.Stderr = stderr | |
| 140 | + | cmd.WaitDelay = cleanup | |
| 141 | + | runErr := run(runCtx, cmd) | |
| 142 | + | if parent.Err() != nil { | |
| 143 | + | return result, parent.Err() | |
| 144 | + | } | |
| 145 | + | rejected := false | |
| 146 | + | for line := range strings.SplitSeq(stdout.buf.String(), "\n") { | |
| 147 | + | fields := strings.Split(line, "\t") | |
| 148 | + | if len(fields) < 3 || len(fields[0]) != 1 { | |
| 149 | + | continue | |
| 150 | + | } | |
| 151 | + | var status ports.MirrorRefStatus | |
| 152 | + | switch fields[0] { | |
| 153 | + | case " ", "*": | |
| 154 | + | status = ports.MirrorRefOK | |
| 155 | + | case "=": | |
| 156 | + | status = ports.MirrorRefUpToDate | |
| 157 | + | case "!": | |
| 158 | + | status = ports.MirrorRefRejected | |
| 159 | + | rejected = true | |
| 160 | + | default: | |
| 161 | + | continue | |
| 162 | + | } | |
| 163 | + | _, ref, _ := strings.Cut(fields[1], ":") | |
| 164 | + | result.Refs = append(result.Refs, ports.MirrorRefOutcome{Ref: ref, Status: status, Reason: safeDetail(fields[2], req.URL)}) | |
| 165 | + | } | |
| 166 | + | if stdout.exceeded || stderr.exceeded { | |
| 167 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror output limit exceeded"} | |
| 168 | + | } | |
| 169 | + | if runCtx.Err() != nil { | |
| 170 | + | return result, &domain.MirrorError{Class: domain.MirrorClassTransient, Detail: "mirror push timed out"} | |
| 171 | + | } | |
| 172 | + | if runErr != nil || rejected { | |
| 173 | + | // The remote's own messages ("remote: ...") are not ssh/git diagnostics: a hosting | |
| 174 | + | // message must not pick the failure class. They stay in the detail. | |
| 175 | + | class := domain.ClassifyMirrorOutput(withoutRemoteLines(stderr.buf.String())+"\n"+stdout.buf.String(), runErr, false) | |
| 176 | + | if rejected && class == domain.MirrorClassNone { | |
| 177 | + | class = domain.MirrorClassDiverged | |
| 178 | + | } | |
| 179 | + | var execErr *exec.Error | |
| 180 | + | if errors.As(runErr, &execErr) || localGitError(stderr.buf.String()) { | |
| 181 | + | class = domain.MirrorClassInternal | |
| 182 | + | } | |
| 183 | + | detail := safeDetail(stderr.buf.String(), req.URL) | |
| 184 | + | if detail == "" { | |
| 185 | + | detail = "git push failed" | |
| 186 | + | } | |
| 187 | + | return result, &domain.MirrorError{Class: class, Detail: detail} | |
| 188 | + | } | |
| 189 | + | return result, nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // withoutRemoteLines drops the lines the remote sent through git ("remote: ..."). | |
| 193 | + | func withoutRemoteLines(s string) string { | |
| 194 | + | lines := strings.Split(s, "\n") | |
| 195 | + | kept := lines[:0] | |
| 196 | + | for _, l := range lines { | |
| 197 | + | if !strings.HasPrefix(strings.TrimSpace(l), "remote:") { | |
| 198 | + | kept = append(kept, l) | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | return strings.Join(kept, "\n") | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func localGitError(stderr string) bool { | |
| 205 | + | for _, marker := range []string{"cannot change to", "not a git repository", "src refspec", "bad object", "unable to read"} { | |
| 206 | + | if strings.Contains(strings.ToLower(stderr), marker) { | |
| 207 | + | return true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // maxDetail caps a detail in bytes; a cut inside a multibyte character is repaired. | |
| 214 | + | const maxDetail = 4096 | |
| 215 | + | ||
| 216 | + | // safeDetail removes the complete target before taking a bounded stderr tail. | |
| 217 | + | // SSH diagnostics may repeat the user separately, so suppress user@ tokens too. | |
| 218 | + | func safeDetail(s, url string) string { | |
| 219 | + | s = strings.ReplaceAll(s, url, "[mirror target]") | |
| 220 | + | words := strings.Fields(s) | |
| 221 | + | for i, w := range words { | |
| 222 | + | if strings.Contains(w, "@") || strings.Contains(w, "://") { | |
| 223 | + | words[i] = "[redacted]" | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | s = strings.Join(words, " ") | |
| 227 | + | if len(s) > maxDetail { | |
| 228 | + | s = s[len(s)-maxDetail:] | |
| 229 | + | for len(s) > 0 && !utf8.RuneStart(s[0]) { // do not start inside a character | |
| 230 | + | s = s[1:] | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | return strings.ToValidUTF8(s, "?") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | type limitWriter struct { | |
| 237 | + | buf bytes.Buffer | |
| 238 | + | max int | |
| 239 | + | exceeded bool | |
| 240 | + | cancel context.CancelFunc | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func (w *limitWriter) Write(p []byte) (int, error) { | |
| 244 | + | if len(p) > w.max-w.buf.Len() { | |
| 245 | + | w.exceeded = true | |
| 246 | + | w.cancel() | |
| 247 | + | return 0, errors.New("output limit reached") | |
| 248 | + | } | |
| 249 | + | return w.buf.Write(p) | |
| 250 | + | } | |
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 | 65 | _, err := i.db.Apply(ctx, &b) | |
| 66 | 66 | return err | |
| 67 | 67 | } | |
| 68 | + | ||
| 69 | + | // RawMirrorKey is the stored protobuf of the mirror key, for encryption-at-rest tests. | |
| 70 | + | func (s *Store) RawMirrorKey(ctx context.Context) ([]byte, bool) { | |
| 71 | + | item, ok := s.db.Get(ctx, []byte(domain.MirrorKeyPath)) | |
| 72 | + | return item.Value, ok | |
| 73 | + | } | |
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 | 164 | keys = append(keys, pendingKeys(ctx, snap, name)...) | |
| 165 | 165 | keys = append(keys, ciKeys(ctx, snap, name)...) | |
| 166 | 166 | keys = append(keys, settingsKeys(ctx, snap, name)...) | |
| 167 | + | keys = append(keys, mirrorKeys(ctx, snap, name)...) | |
| 167 | 168 | for len(keys) > deleteChunk { | |
| 168 | 169 | var b kv.Batch | |
| 169 | 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 | 48 | // Config configures the API. | |
| 49 | 49 | type Config struct { | |
| 50 | - | Auth ports.AuthService | |
| 51 | - | Repos ports.RepoService | |
| 52 | - | Tokens ports.TokenService | |
| 53 | - | Browse ports.BrowseService | |
| 54 | - | Events ports.EventService | |
| 50 | + | // Mirrors serves MirrorService when set; leave unset rather than a typed nil. | |
| 51 | + | Mirrors ports.MirrorService | |
| 52 | + | Auth ports.AuthService | |
| 53 | + | Repos ports.RepoService | |
| 54 | + | Tokens ports.TokenService | |
| 55 | + | Browse ports.BrowseService | |
| 56 | + | Events ports.EventService | |
| 55 | 57 | // MergeRequests serves MergeRequestService, SigningKey GetSigningKey. | |
| 56 | 58 | MergeRequests ports.MergeRequestService | |
| 57 | 59 | SigningKey ports.SigningKeyService | |
| @@ -125,6 +127,10 @@ func New(cfg Config) *API { | |||
| 125 | 127 | mux.Handle(ermiteconnect.NewTokenServiceHandler(&tokenService{cfg.Tokens}, opts...)) | |
| 126 | 128 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | |
| 127 | 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 | 134 | if cfg.Passkeys != nil { | |
| 129 | 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 | 523 | case domain.JobUpdated: | |
| 524 | 524 | out.Time = timestamp(ev.At) | |
| 525 | 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 | 532 | default: | |
| 527 | 533 | // Web-only events, such as domain.IssuePendingRemoved (the API has | |
| 528 | 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 | 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 | 106 | <section aria-labelledby="r-clone"><h2 id="r-clone">Clone</h2>@CloneBlock(p.Clone) | |
| 96 | 107 | </section> | |
| 97 | 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 | 49 | now := s.cfg.Now() | |
| 50 | 50 | ||
| 51 | 51 | p := SummaryPage{ | |
| 52 | + | Mirror: s.mirrorBadge(ctx, d.repo.Name), | |
| 52 | 53 | AllHref: repoHref(d.name(), "commits", ref), | |
| 53 | 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 | 204 | // SummaryPage is the repository summary. | |
| 205 | 205 | type SummaryPage struct { | |
| 206 | + | Mirror *MirrorBadge | |
| 206 | 207 | Facts []Fact | |
| 207 | 208 | Clone Clone | |
| 208 | 209 | Recent []CommitRow | |
M internal/adapters/web/web.go +3 -0
| @@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second | |||
| 47 | 47 | ||
| 48 | 48 | // Config configures a Server. | |
| 49 | 49 | type Config struct { | |
| 50 | + | // Mirrors answers the push mirror badge shown to repository admins. Without it there is | |
| 51 | + | // no badge. Leave it unset rather than assigning a typed nil pointer. | |
| 52 | + | Mirrors ports.MirrorWarner | |
| 50 | 53 | // Repos and Browse are the use cases the pages are built from. | |
| 51 | 54 | Repos ports.RepoService | |
| 52 | 55 | Browse ports.BrowseService | |
M internal/app/api_test.go +1 -1
| @@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 196 | 196 | for _, who := range allPrincipals { | |
| 197 | 197 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 198 | 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 | 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 | 350 | ver := decode[versionOut](t, res.stdout) | |
| 351 | 351 | assert.Equal(t, c.url, ver.Server.URL) | |
| 352 | 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 | 354 | assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures) | |
| 355 | 355 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 356 | 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 | 31 | Comment *jsonComment `json:"comment,omitempty"` | |
| 32 | 32 | // Mergeability is set for a merge_request_mergeability event. | |
| 33 | 33 | Mergeability *jsonMergeability `json:"mergeability,omitempty"` | |
| 34 | + | // State and FailureClass are set for a mirror_failed event. | |
| 35 | + | State string `json:"state,omitempty"` | |
| 36 | + | FailureClass string `json:"failure_class,omitempty"` | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | type jsonRefUpdate struct { | |
| @@ -55,6 +58,8 @@ const ( | |||
| 55 | 58 | eventCommentDeleted = "comment_deleted" | |
| 56 | 59 | eventCommentResolved = "comment_resolved" | |
| 57 | 60 | eventMRMergeability = "merge_request_mergeability" | |
| 61 | + | eventMirrorFailed = "mirror_failed" | |
| 62 | + | eventMirrorRecovered = "mirror_recovered" | |
| 58 | 63 | ) | |
| 59 | 64 | ||
| 60 | 65 | // toJSONEvent maps an event; ok is false for kinds this client does not know, | |
| @@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) { | |||
| 100 | 105 | case *ermite.Event_MergeRequestMergeability: | |
| 101 | 106 | out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber() | |
| 102 | 107 | out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability()) | |
| 108 | + | case *ermite.Event_MirrorFailed: | |
| 109 | + | out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo() | |
| 110 | + | out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass() | |
| 111 | + | case *ermite.Event_MirrorRecovered: | |
| 112 | + | out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo() | |
| 103 | 113 | case *ermite.Event_Gap: | |
| 104 | 114 | out.Type = eventGap | |
| 105 | 115 | default: | |
| @@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string { | |||
| 134 | 144 | detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author) | |
| 135 | 145 | case eventMRMergeability: | |
| 136 | 146 | detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability)) | |
| 147 | + | case eventMirrorFailed: | |
| 148 | + | detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")" | |
| 149 | + | case eventMirrorRecovered: | |
| 150 | + | detail = e.Repo | |
| 137 | 151 | case eventGap: | |
| 138 | 152 | detail = "events were lost, reload what you display" | |
| 139 | 153 | } | |
M internal/app/cli_watch_test.go +17 -0
| @@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) { | |||
| 266 | 266 | assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`) | |
| 267 | 267 | } | |
| 268 | 268 | } | |
| 269 | + | ||
| 270 | + | func TestMirrorWatchEvents(t *testing.T) { | |
| 271 | + | failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "") | |
| 272 | + | require.True(t, ok) | |
| 273 | + | assert.Equal(t, "mirror_failed", failed.Type) | |
| 274 | + | assert.Equal(t, "alpha", failed.Repo) | |
| 275 | + | assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)") | |
| 276 | + | raw, err := json.Marshal(failed) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | assert.Contains(t, string(raw), `"state":"paused"`) | |
| 279 | + | assert.Contains(t, string(raw), `"failure_class":"diverged"`) | |
| 280 | + | recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "") | |
| 281 | + | require.True(t, ok) | |
| 282 | + | assert.Equal(t, "mirror_recovered", recovered.Type) | |
| 283 | + | assert.Contains(t, eventLine(recovered), "mirror_recovered alpha") | |
| 284 | + | assert.NotContains(t, eventLine(recovered), " \n") | |
| 285 | + | } | |
M internal/app/forge.go +33 -1
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | "bnema.dev/ermite/internal/adapters/githttp" | |
| 27 | 27 | "bnema.dev/ermite/internal/adapters/githubapi" | |
| 28 | 28 | "bnema.dev/ermite/internal/adapters/githubraw" | |
| 29 | + | "bnema.dev/ermite/internal/adapters/gitpush" | |
| 29 | 30 | "bnema.dev/ermite/internal/adapters/gitremote" | |
| 30 | 31 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 31 | 32 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| @@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes | |||
| 49 | 50 | ||
| 50 | 51 | // options are the parts of the wiring that tests replace. | |
| 51 | 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 | 56 | // hookExe is the executable the hook scripts exec; empty means this | |
| 53 | 57 | // process's own executable. | |
| 54 | 58 | hookExe string | |
| @@ -151,6 +155,8 @@ type forge struct { | |||
| 151 | 155 | CI *usecase.CI | |
| 152 | 156 | // Settings are the secrets, variables and environments of CI. | |
| 153 | 157 | Settings *usecase.CISettings | |
| 158 | + | // Mirrors exposes Warning and Badge for the hook server and web composition. | |
| 159 | + | Mirrors *usecase.Mirrors | |
| 154 | 160 | // OIDC is the provider of ID tokens, disabled without a secrets key. | |
| 155 | 161 | OIDC *usecase.OIDC | |
| 156 | 162 | API *rpc.API | |
| @@ -274,6 +280,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 274 | 280 | } | |
| 275 | 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 | 304 | f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock) | |
| 278 | 305 | if err != nil { | |
| 279 | 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 | 369 | CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability, | |
| 343 | 370 | }) | |
| 344 | 371 | f.Mergeability.Start() | |
| 345 | - | f.MergeRequests.WithVerifier(f.Signatures) | |
| 372 | + | f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors) | |
| 346 | 373 | f.Issues = usecase.NewIssues(usecase.IssuesConfig{ | |
| 347 | 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 | 428 | f.API = rpc.New(rpc.Config{ | |
| 402 | 429 | Auth: f.Auth, | |
| 430 | + | Mirrors: f.Mirrors, | |
| 403 | 431 | Repos: f.Repos, | |
| 404 | 432 | Tokens: f.Tokens, | |
| 405 | 433 | Browse: f.Browse, | |
| @@ -489,6 +517,7 @@ func (f *forge) Run(ctx context.Context) error { | |||
| 489 | 517 | // outlives the HTTP server: requests still in flight while it drains are | |
| 490 | 518 | // pushes whose hooks need it. A failure of either stops both. | |
| 491 | 519 | func (f *forge) Serve(ctx context.Context, ln net.Listener) error { | |
| 520 | + | f.Mirrors.Start() | |
| 492 | 521 | ctx, cancel := context.WithCancel(ctx) | |
| 493 | 522 | defer cancel() | |
| 494 | 523 | hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx)) | |
| @@ -517,6 +546,9 @@ func (f *forge) Close(ctx context.Context) { | |||
| 517 | 546 | if f.MergeRequests != nil { | |
| 518 | 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 | 552 | if f.CI != nil { | |
| 521 | 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 | 31 | root.AddCommand( | |
| 32 | 32 | newServeCmd(), newHookCmd(), | |
| 33 | - | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 33 | + | newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 34 | 34 | newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env), | |
| 35 | 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 | 58 | Runs: f.CI, | |
| 59 | 59 | Artifacts: artifacts, | |
| 60 | 60 | Events: f.EventLog, | |
| 61 | + | Mirrors: f.Mirrors, | |
| 61 | 62 | Owner: cfg.Owner, | |
| 62 | 63 | PublicURL: cfg.PublicURL, | |
| 63 | 64 | SourceURL: cfg.SourceURL, | |
M internal/domain/event.go +22 -0
| @@ -304,3 +304,25 @@ type EventGap struct{} | |||
| 304 | 304 | ||
| 305 | 305 | // EventKind implements Event. | |
| 306 | 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 | 167 | return bufferedEvent{known: true, vis: ev.Visibility, created: ev.CreatedAt} | |
| 168 | 168 | case domain.RepoDeleted: | |
| 169 | 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 | 174 | case domain.PushEvent: | |
| 171 | 175 | if ev.CreatedAt.IsZero() { | |
| 172 | 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 | 521 | return e.deliverRepo(ctx, w, ev.Repo, true, be, out, send) | |
| 518 | 522 | case domain.RepoUpdated: | |
| 519 | 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 | 528 | case domain.PushEvent: | |
| 521 | 529 | return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send) | |
| 522 | 530 | case domain.MergeRequestOpened: | |
M internal/usecase/mergerequests.go +10 -0
| @@ -58,6 +58,7 @@ type MergeRequests struct { | |||
| 58 | 58 | tips ports.CIRefTipStore // nil: who merges is not recorded | |
| 59 | 59 | verifier ports.CommitVerifier // nil: commits are not checked (ADR 0030) | |
| 60 | 60 | mergeab MergeabilityHook // nil: no mergeability | |
| 61 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 61 | 62 | ||
| 62 | 63 | mu sync.Mutex | |
| 63 | 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 | 126 | // WithVerifier makes GetMergeRequest fill the signature state of the commits | |
| 120 | 127 | // it lists (ADR 0030). | |
| 121 | 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 | 1179 | m.bus.Publish(ctx, domain.PushEvent{ | |
| 1173 | 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 | 1185 | if m.pulls != nil { | |
| 1176 | 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 | 18 | // Pushes are the use cases around a Git push. | |
| 19 | 19 | type Pushes struct { | |
| 20 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 20 | 21 | meta ports.RepoMetaStore | |
| 21 | 22 | repos *Repos | |
| 22 | 23 | git ports.GitRepos | |
| @@ -43,6 +44,12 @@ func NewPushes(meta ports.RepoMetaStore, repos *Repos, git ports.GitRepos, bus p | |||
| 43 | 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 | 53 | // WithFreshener makes Record tell the reader that a repository changed, so that | |
| 47 | 54 | // it serves the push at once. Call it before the first push; it returns u. | |
| 48 | 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 | 269 | Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now, | |
| 263 | 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 | 275 | u.maint.Schedule(ctx, name) | |
| 266 | 276 | log.Info(). | |
| 267 | 277 | Str("repo", name.String()).Int("refs", len(updates)). | |
M internal/usecase/repos.go +13 -5
| @@ -17,11 +17,12 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | // Repos are the repository use cases. | |
| 19 | 19 | type Repos struct { | |
| 20 | - | meta ports.RepoMetaStore | |
| 21 | - | git ports.GitRepos | |
| 22 | - | bus ports.EventBus | |
| 23 | - | clock ports.Clock | |
| 24 | - | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 20 | + | meta ports.RepoMetaStore | |
| 21 | + | git ports.GitRepos | |
| 22 | + | bus ports.EventBus | |
| 23 | + | clock ports.Clock | |
| 24 | + | ci ports.CIRunCleaner // nil: no CI logs to remove | |
| 25 | + | mirror ports.MirrorTrigger // nil: no outbound mirror | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | var _ ports.RepoService = (*Repos)(nil) | |
| @@ -32,6 +33,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus, | |||
| 32 | 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 | 43 | // WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it | |
| 36 | 44 | // before the first request; it returns r. | |
| 37 | 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 | 129 | if !metaDone { | |
| 130 | 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 | + | } | |