detached 067ccd6

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

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

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

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

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

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

Checks 8

69 files · +8558 -290
8/8
M README.md +1 -0
M docs/ci-events.md +9 -0
M docs/cli.md +8 -1
A docs/mirror.md +82 -0
A gen/ermite/storage/mirror.pb.go +577 -0
M go.mod +1 -1
M go.sum +2 -2
M internal/adapters/client/client.go +5 -0
M internal/adapters/githttp/hooks.go +30 -3
M internal/adapters/githttp/hooks_test.go +46 -0
A internal/adapters/gitpush/gitpush.go +258 -0
A internal/adapters/gitpush/gitpush_test.go +197 -0
A internal/adapters/gitpush/run_linux.go +107 -0
A internal/adapters/gitpush/run_other.go +33 -0
M internal/adapters/kvstore/export_test.go +6 -0
A internal/adapters/kvstore/mirror.go +201 -0
A internal/adapters/kvstore/mirror_test.go +125 -0
M internal/adapters/kvstore/repos.go +1 -0
M internal/adapters/rpc/api.go +11 -5
M internal/adapters/rpc/convert.go +6 -0
A internal/adapters/rpc/mirror.go +204 -0
A internal/adapters/rpc/mirror_test.go +108 -0
A internal/adapters/web/mirror.go +27 -0
A internal/adapters/web/mirror_test.go +53 -0
M internal/adapters/web/pages.templ +11 -0
M internal/adapters/web/pages_templ.go +324 -269 generated file: collapsed
M internal/adapters/web/summary.go +1 -0
@@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
4949 now := s.cfg.Now()
5050
5151 p := SummaryPage{
52+ Mirror: s.mirrorBadge(ctx, d.repo.Name),
5253 AllHref: repoHref(d.name(), "commits", ref),
5354 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
5455 }
@@ -49,6 +49,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
49 now := s.cfg.Now()49 now := s.cfg.Now()
50 50
51 p := SummaryPage{51 p := SummaryPage{
52+ Mirror: s.mirrorBadge(ctx, d.repo.Name),
52 AllHref: repoHref(d.name(), "commits", ref),53 AllHref: repoHref(d.name(), "commits", ref),
53 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},54 Clone: Clone{ID: "clone-" + d.name(), Lines: []string{"git clone " + s.cloneURL(d.name())}},
54 }55 }
M internal/adapters/web/view.go +1 -0
@@ -203,6 +203,7 @@ type CommitRow struct {
203203
204204 // SummaryPage is the repository summary.
205205 type SummaryPage struct {
206+ Mirror *MirrorBadge
206207 Facts []Fact
207208 Clone Clone
208209 Recent []CommitRow
@@ -203,6 +203,7 @@ type CommitRow struct {
203 203
204 // SummaryPage is the repository summary.204 // SummaryPage is the repository summary.
205 type SummaryPage struct {205 type SummaryPage struct {
206+ Mirror *MirrorBadge
206 Facts []Fact207 Facts []Fact
207 Clone Clone208 Clone Clone
208 Recent []CommitRow209 Recent []CommitRow
M internal/adapters/web/web.go +3 -0
@@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second
4747
4848 // Config configures a Server.
4949 type Config struct {
50+ // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51+ // no badge. Leave it unset rather than assigning a typed nil pointer.
52+ Mirrors ports.MirrorWarner
5053 // Repos and Browse are the use cases the pages are built from.
5154 Repos ports.RepoService
5255 Browse ports.BrowseService
@@ -47,6 +47,9 @@ const requestTimeout = 30 * time.Second
47 47
48 // Config configures a Server.48 // Config configures a Server.
49 type Config struct {49 type Config struct {
50+ // Mirrors answers the push mirror badge shown to repository admins. Without it there is
51+ // no badge. Leave it unset rather than assigning a typed nil pointer.
52+ Mirrors ports.MirrorWarner
50 // Repos and Browse are the use cases the pages are built from.53 // Repos and Browse are the use cases the pages are built from.
51 Repos ports.RepoService54 Repos ports.RepoService
52 Browse ports.BrowseService55 Browse ports.BrowseService
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196 for _, who := range allPrincipals {196 for _, who := range allPrincipals {
197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198 require.NoError(t, err, who)198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, resp.Msg.GetFeatures(), who)199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, resp.Msg.GetFeatures(), who)
200 assert.NotEmpty(t, resp.Msg.GetVersion())200 assert.NotEmpty(t, resp.Msg.GetVersion())
201 }201 }
202 })202 })
M internal/app/cli_e2e_test.go +1 -1
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350350 ver := decode[versionOut](t, res.stdout)
351351 assert.Equal(t, c.url, ver.Server.URL)
352352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)
353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355355 stdout, stderr, code = c.bin(t, secret, "version")
356356 require.Equal(t, 0, code, stderr)
@@ -350,7 +350,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
350 ver := decode[versionOut](t, res.stdout)350 ver := decode[versionOut](t, res.stdout)
351 assert.Equal(t, c.url, ver.Server.URL)351 assert.Equal(t, c.url, ver.Server.URL)
352 assert.NotEmpty(t, ver.Server.Version)352 assert.NotEmpty(t, ver.Server.Version)
353- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures"}, ver.Server.Features)353+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "issues", "merge_requests.mergeability", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners", "ci.actions", "ci.annotations", "ci.summaries", "ci.concurrency", "ci.dispatch", "ci.workflows", "ci.cache", "ci.secrets", "ci.artifacts", "commit_signatures", "mirror"}, ver.Server.Features)
354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)354 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
355 stdout, stderr, code = c.bin(t, secret, "version")355 stdout, stderr, code = c.bin(t, secret, "version")
356 require.Equal(t, 0, code, stderr)356 require.Equal(t, 0, code, stderr)
A internal/app/cli_mirror.go +320 -0
@@ -0,0 +1,320 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+
10+ "connectrpc.com/connect"
11+ "github.com/spf13/cobra"
12+
13+ "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+const jsonMirrorKeyHelp = `
17+JSON fields of a key (--json): authorized_key, fingerprint, created_at (RFC 3339, UTC).`
18+
19+const jsonMirrorConfigHelp = `
20+JSON fields of the policy (--json): enabled, url_template, cron, all_branches, updated_at (RFC 3339, UTC, null if never set).`
21+
22+const jsonMirrorStatusHelp = `
23+JSON fields of a status (--json): repo, enabled, source (disabled, global, override, excluded), url
24+(admins only, else empty), cron, all_branches, state (disabled, ok, failing, paused), failure_class,
25+reason, detail (admins only), consecutive_failures, last_attempt_at, last_success_at, next_retry_at,
26+next_run_at, paused_at (RFC 3339, UTC, null when unset).`
27+
28+type jsonMirrorKey struct {
29+ AuthorizedKey string `json:"authorized_key"`
30+ Fingerprint string `json:"fingerprint"`
31+ CreatedAt *string `json:"created_at"`
32+}
33+
34+type jsonMirrorConfig struct {
35+ Enabled bool `json:"enabled"`
36+ URLTemplate string `json:"url_template"`
37+ Cron string `json:"cron"`
38+ AllBranches bool `json:"all_branches"`
39+ UpdatedAt *string `json:"updated_at"`
40+}
41+
42+type jsonMirrorStatus struct {
43+ Repo string `json:"repo"`
44+ Enabled bool `json:"enabled"`
45+ Source string `json:"source"`
46+ URL string `json:"url"`
47+ Cron string `json:"cron"`
48+ AllBranches bool `json:"all_branches"`
49+ State string `json:"state"`
50+ FailureClass string `json:"failure_class"`
51+ Reason string `json:"reason"`
52+ Detail string `json:"detail"`
53+ ConsecutiveFailures int32 `json:"consecutive_failures"`
54+ LastAttemptAt *string `json:"last_attempt_at"`
55+ LastSuccessAt *string `json:"last_success_at"`
56+ NextRetryAt *string `json:"next_retry_at"`
57+ NextRunAt *string `json:"next_run_at"`
58+ PausedAt *string `json:"paused_at"`
59+}
60+
61+func toJSONMirrorKey(k *ermite.MirrorKey) jsonMirrorKey {
62+ return jsonMirrorKey{AuthorizedKey: k.GetAuthorizedKey(), Fingerprint: k.GetFingerprint(), CreatedAt: jsonTime(k.GetCreatedAt())}
63+}
64+
65+func toJSONMirrorConfig(g *ermite.MirrorConfig) jsonMirrorConfig {
66+ return jsonMirrorConfig{Enabled: g.GetEnabled(), URLTemplate: g.GetUrlTemplate(), Cron: g.GetCron(), AllBranches: g.GetAllBranches(), UpdatedAt: jsonTime(g.GetUpdatedAt())}
67+}
68+
69+func mirrorSourceName(s ermite.MirrorSource) string {
70+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_SOURCE_"))
71+}
72+
73+func toJSONMirrorStatus(s *ermite.MirrorStatus) jsonMirrorStatus {
74+ t := s.GetTarget()
75+ return jsonMirrorStatus{
76+ Repo: t.GetRepo(), Enabled: t.GetEnabled(), Source: mirrorSourceName(t.GetSource()), URL: t.GetUrl(), Cron: t.GetCron(), AllBranches: t.GetAllBranches(),
77+ State: mirrorStateName(s.GetState()), FailureClass: s.GetFailureClass(), Reason: s.GetReason(), Detail: s.GetDetail(), ConsecutiveFailures: s.GetConsecutiveFailures(),
78+ LastAttemptAt: jsonTime(s.GetLastAttemptAt()), LastSuccessAt: jsonTime(s.GetLastSuccessAt()), NextRetryAt: jsonTime(s.GetNextRetryAt()), NextRunAt: jsonTime(s.GetNextRunAt()), PausedAt: jsonTime(s.GetPausedAt()),
79+ }
80+}
81+
82+func newMirrorCmd(env *cliEnv) *cobra.Command {
83+ cmd := &cobra.Command{Use: "mirror", Short: "Manage non-forcing push mirrors"}
84+ key := &cobra.Command{Use: "key", Short: "Show or rotate the mirror SSH key"}
85+ for _, rotate := range []bool{false, true} {
86+ name, short := "show", "Print the mirror authorized_keys line"
87+ long := "Print only the authorized_keys line on stdout; requires authentication and ERMITE_SECRETS_KEY on the server.\nPipe it into: gh ssh-key add - --title \"ermite mirror\"." + jsonMirrorKeyHelp
88+ if rotate {
89+ name, short = "rotate", "Replace the mirror SSH key (owner only)"
90+ long = "Replace the mirror SSH key at once and print the new authorized_keys line on stdout; the replaced\nkey's fingerprint goes to stderr. Every mirror fails until the new key is added to the remotes.\nRequires owner rights and ERMITE_SECRETS_KEY on the server." + jsonMirrorKeyHelp
91+ }
92+ sub := &cobra.Command{Use: name, Short: short, Args: cobra.NoArgs, Long: long}
93+ asJSON := addJSONFlag(sub)
94+ sub.RunE = func(cmd *cobra.Command, _ []string) error {
95+ c, err := env.client(cmd)
96+ if err != nil {
97+ return err
98+ }
99+ var k *ermite.MirrorKey
100+ if rotate {
101+ res, e := c.Mirrors().RotateMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.RotateMirrorKeyRequest{}))
102+ if e != nil {
103+ return c.Describe(e)
104+ }
105+ k = res.Msg.GetKey()
106+ if prev := res.Msg.GetPreviousFingerprint(); prev != "" {
107+ noticef(cmd, "replaced key %s", clean(prev))
108+ }
109+ noticef(cmd, "remotes refuse pushes until this key is added")
110+ } else {
111+ res, e := c.Mirrors().GetMirrorKey(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorKeyRequest{}))
112+ if e != nil {
113+ return c.Describe(e)
114+ }
115+ k = res.Msg.GetKey()
116+ }
117+ if *asJSON {
118+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorKey(k))
119+ }
120+ _, err = fmt.Fprintln(cmd.OutOrStdout(), clean(k.GetAuthorizedKey()))
121+ return err
122+ }
123+ key.AddCommand(sub)
124+ }
125+ cmd.AddCommand(key, newMirrorConfigCmd(env), newMirrorAddCmd(env), newMirrorStatusCmd(env), newMirrorSyncCmd(env))
126+ for _, name := range []string{"remove", "exclude", "include"} {
127+ sub := &cobra.Command{Use: name + " <repo>", Short: name + " a repository mirror", Args: cobra.ExactArgs(1)}
128+ sub.RunE = func(cmd *cobra.Command, args []string) error {
129+ c, err := env.client(cmd)
130+ if err != nil {
131+ return err
132+ }
133+ if name == "remove" {
134+ _, err = c.Mirrors().RemoveRepoMirror(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRepoMirrorRequest{Repo: args[0]}))
135+ } else {
136+ _, err = c.Mirrors().SetRepoMirrorExcluded(env.ctx(cmd), connect.NewRequest(&ermite.SetRepoMirrorExcludedRequest{Repo: args[0], Excluded: name == "exclude"}))
137+ }
138+ if err != nil {
139+ return c.Describe(err)
140+ }
141+ noticef(cmd, "%s: %s", name, clean(args[0]))
142+ return nil
143+ }
144+ cmd.AddCommand(sub)
145+ }
146+ return cmd
147+}
148+
149+func newMirrorConfigCmd(env *cliEnv) *cobra.Command {
150+ var enabled, all bool
151+ var url, cron string
152+ cmd := &cobra.Command{Use: "config", Short: "Patch the global mirror policy (owner only)", Long: "Change the flags given and keep the other settings; use `config show` to read the policy." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
153+ cmd.Flags().BoolVar(&enabled, "enabled", false, "enable the global mirror policy")
154+ cmd.Flags().StringVar(&url, "url", "", "SSH URL template containing {repo}")
155+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (minimum gap five minutes)")
156+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches instead of only the default branch")
157+ asJSON := addJSONFlag(cmd)
158+ output := func(cmd *cobra.Command, g *ermite.MirrorConfig, json bool) error {
159+ if json {
160+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorConfig(g))
161+ }
162+ return fields(cmd.OutOrStdout(), [][2]string{{"enabled", strconv.FormatBool(g.GetEnabled())}, {"url", g.GetUrlTemplate()}, {"cron (UTC)", g.GetCron()}, {"all branches", strconv.FormatBool(g.GetAllBranches())}})
163+ }
164+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
165+ c, err := env.client(cmd)
166+ if err != nil {
167+ return err
168+ }
169+ req := &ermite.SetMirrorConfigRequest{}
170+ if cmd.Flags().Changed("enabled") {
171+ req.Enabled = &enabled
172+ }
173+ if cmd.Flags().Changed("url") {
174+ req.UrlTemplate = &url
175+ }
176+ if cmd.Flags().Changed("cron") {
177+ req.Cron = &cron
178+ }
179+ if cmd.Flags().Changed("all-branches") {
180+ req.AllBranches = &all
181+ }
182+ if req.Enabled == nil && req.UrlTemplate == nil && req.Cron == nil && req.AllBranches == nil {
183+ return fmt.Errorf("specify at least one config flag, or use `mirror config show`")
184+ }
185+ res, err := c.Mirrors().SetMirrorConfig(env.ctx(cmd), connect.NewRequest(req))
186+ if err != nil {
187+ return c.Describe(err)
188+ }
189+ return output(cmd, res.Msg.GetConfig(), *asJSON)
190+ }
191+ show := &cobra.Command{Use: "show", Short: "Show the global mirror policy", Long: "Show the global mirror policy (owner only)." + jsonMirrorConfigHelp, Args: cobra.NoArgs}
192+ showJSON := addJSONFlag(show)
193+ show.RunE = func(cmd *cobra.Command, _ []string) error {
194+ c, err := env.client(cmd)
195+ if err != nil {
196+ return err
197+ }
198+ res, err := c.Mirrors().GetMirrorConfig(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorConfigRequest{}))
199+ if err != nil {
200+ return c.Describe(err)
201+ }
202+ return output(cmd, res.Msg.GetConfig(), *showJSON)
203+ }
204+ cmd.AddCommand(show)
205+ return cmd
206+}
207+
208+func newMirrorAddCmd(env *cliEnv) *cobra.Command {
209+ var cron string
210+ var all bool
211+ cmd := &cobra.Command{Use: "add <repo> <url>", Short: "Set a repository mirror override", Args: cobra.ExactArgs(2)}
212+ cmd.Flags().StringVar(&cron, "cron", "", "five-field UTC cron (empty inherits global)")
213+ cmd.Flags().BoolVar(&all, "all-branches", false, "mirror all branches (omitted inherits global)")
214+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
215+ c, err := env.client(cmd)
216+ if err != nil {
217+ return err
218+ }
219+ req := &ermite.SetRepoMirrorRequest{Repo: args[0], Url: args[1], Cron: cron}
220+ if cmd.Flags().Changed("all-branches") {
221+ req.AllBranches = &all
222+ }
223+ _, err = c.Mirrors().SetRepoMirror(env.ctx(cmd), connect.NewRequest(req))
224+ if err != nil {
225+ return c.Describe(err)
226+ }
227+ noticef(cmd, "mirror configured for %s", clean(args[0]))
228+ return nil
229+ }
230+ return cmd
231+}
232+
233+func mirrorStateName(s ermite.MirrorState) string {
234+ return strings.ToLower(strings.TrimPrefix(s.String(), "MIRROR_STATE_"))
235+}
236+
237+func newMirrorStatusCmd(env *cliEnv) *cobra.Command {
238+ cmd := &cobra.Command{Use: "status [repo]", Short: "Show mirror states, reasons and next runs", Long: "Show one repository, or every repository (owner only).\n--json prints an object for one repository and an array for all." + jsonMirrorStatusHelp, Args: cobra.MaximumNArgs(1)}
239+ asJSON := addJSONFlag(cmd)
240+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
241+ c, err := env.client(cmd)
242+ if err != nil {
243+ return err
244+ }
245+ var statuses []*ermite.MirrorStatus
246+ if len(args) == 1 {
247+ res, err := c.Mirrors().GetMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.GetMirrorStatusRequest{Repo: args[0]}))
248+ if err != nil {
249+ return c.Describe(err)
250+ }
251+ if *asJSON {
252+ return writeJSON(cmd.OutOrStdout(), toJSONMirrorStatus(res.Msg.GetStatus()))
253+ }
254+ statuses = append(statuses, res.Msg.GetStatus())
255+ } else {
256+ res, err := c.Mirrors().ListMirrorStatus(env.ctx(cmd), connect.NewRequest(&ermite.ListMirrorStatusRequest{}))
257+ if err != nil {
258+ return c.Describe(err)
259+ }
260+ if *asJSON {
261+ items := make([]jsonMirrorStatus, 0, len(res.Msg.GetStatuses()))
262+ for _, st := range res.Msg.GetStatuses() {
263+ items = append(items, toJSONMirrorStatus(st))
264+ }
265+ return writeJSON(cmd.OutOrStdout(), items)
266+ }
267+ statuses = res.Msg.GetStatuses()
268+ }
269+ rows := make([][]string, 0, len(statuses))
270+ for _, s := range statuses {
271+ rows = append(rows, []string{s.GetTarget().GetRepo(), mirrorStateName(s.GetState()), s.GetReason(), shortTime(s.GetLastSuccessAt(), "-"), shortTime(s.GetNextRunAt(), "-"), strconv.Itoa(int(s.GetConsecutiveFailures()))})
272+ }
273+ return table(cmd.OutOrStdout(), []string{"REPO", "STATE", "REASON", "LAST SUCCESS", "NEXT RUN", "FAILURES"}, rows)
274+ }
275+ return cmd
276+}
277+
278+func newMirrorSyncCmd(env *cliEnv) *cobra.Command {
279+ var all, force bool
280+ cmd := &cobra.Command{Use: "sync <repo>|--all", Short: "Sync mirrors now", Long: "Sync one repository, or every repository with --all (owner only).\n--force never force-pushes: it only skips the no-op check and throttle and lifts a pause.", Args: func(_ *cobra.Command, args []string) error {
281+ if (all && len(args) == 0) || (!all && len(args) == 1) {
282+ return nil
283+ }
284+ return fmt.Errorf("specify exactly one repository or --all")
285+ }}
286+ cmd.Flags().BoolVar(&all, "all", false, "sync all repository mirrors")
287+ cmd.Flags().BoolVar(&force, "force", false, "skip no-op check/throttle and lift pause; never force-push")
288+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
289+ c, err := env.client(cmd)
290+ if err != nil {
291+ return err
292+ }
293+ req := &ermite.SyncMirrorRequest{All: all, Force: force}
294+ if !all {
295+ req.Repo = args[0]
296+ }
297+ res, err := c.Mirrors().SyncMirror(env.ctx(cmd), connect.NewRequest(req))
298+ if err != nil {
299+ return c.Describe(err)
300+ }
301+ failed := 0
302+ rows := make([][]string, 0, len(res.Msg.GetResults()))
303+ for _, r := range res.Msg.GetResults() {
304+ reason := r.GetStatus().GetReason()
305+ if r.GetError() != "" {
306+ failed++
307+ reason = r.GetError()
308+ }
309+ rows = append(rows, []string{r.GetRepo(), mirrorStateName(r.GetStatus().GetState()), strconv.FormatBool(r.GetQueued()), strconv.FormatBool(r.GetNoOp()), reason})
310+ }
311+ if err := table(cmd.OutOrStdout(), []string{"REPO", "STATE", "QUEUED", "NO-OP", "REASON"}, rows); err != nil {
312+ return err
313+ }
314+ if failed > 0 {
315+ return fmt.Errorf("%d of %d mirror syncs failed", failed, len(rows))
316+ }
317+ return nil
318+ }
319+ return cmd
320+}
@@ -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+}
@@ -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+}
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestMirrorCLIArguments(t *testing.T) {
14+ for _, args := range [][]string{
15+ {"mirror", "sync"}, {"mirror", "sync", "repo", "--all"}, {"mirror", "sync", "a", "b"},
16+ {"mirror", "add", "repo"}, {"mirror", "remove"}, {"mirror", "exclude"}, {"mirror", "include"},
17+ {"mirror", "key", "show", "extra"}, {"mirror", "key", "rotate", "extra"}, {"mirror", "status", "a", "b"},
18+ } {
19+ t.Run(strings.Join(args, " "), func(t *testing.T) {
20+ r := runCLI(t, nil, "", args...)
21+ require.Error(t, r.err)
22+ assert.NotContains(t, r.err.Error(), "ERMITE_URL", "arguments must be rejected before loading credentials")
23+ })
24+ }
25+ r := runCLI(t, nil, "", "mirror", "sync", "--help").ok(t)
26+ assert.Contains(t, r.stdout, "never force-pushes")
27+ assert.Contains(t, r.stdout, "lifts a pause")
28+}
29+
30+func TestMirrorCLIConfiguration(t *testing.T) {
31+ h := newSecretsCLI(t)
32+ run := func(args ...string) result { return h.cli(t, adminToken, append([]string{"mirror"}, args...)...) }
33+ run("config", "--enabled", "--url", "git@example.test:owner/{repo}.git", "--cron", "*/5 * * * *", "--all-branches").ok(t)
34+ r := run("config", "show", "--json").ok(t)
35+ g := decode[map[string]any](t, r.stdout)
36+ assert.Equal(t, true, g["enabled"])
37+ assert.Equal(t, true, g["all_branches"])
38+ assert.Equal(t, "git@example.test:owner/{repo}.git", g["url_template"])
39+ run("config", "--enabled=false", "--all-branches=false").ok(t)
40+ g = decode[map[string]any](t, run("config", "show", "--json").ok(t).stdout)
41+ assert.Equal(t, false, g["enabled"])
42+ assert.Equal(t, false, g["all_branches"])
43+ assert.Equal(t, "*/5 * * * *", g["cron"], "omitted fields are not cleared")
44+ run("add", "proj", "git@example.test:owner/proj.git", "--cron", "*/10 * * * *", "--all-branches=false").ok(t)
45+ r = run("status", "proj", "--json").ok(t)
46+ s := decode[map[string]any](t, r.stdout)
47+ assert.Equal(t, "override", s["source"])
48+ assert.Equal(t, false, s["all_branches"])
49+ assert.Equal(t, "ok", s["state"])
50+ assert.Equal(t, "proj", s["repo"])
51+ assert.Contains(t, run("status").ok(t).stdout, "FAILURES")
52+ assert.Len(t, decode[[]any](t, run("status", "--json").ok(t).stdout), 1)
53+ run("remove", "proj").ok(t)
54+ run("exclude", "proj").ok(t)
55+ s = decode[map[string]any](t, run("status", "proj", "--json").ok(t).stdout)
56+ assert.Equal(t, "excluded", s["source"])
57+ run("include", "proj").ok(t)
58+ first := run("key", "show").ok(t)
59+ assert.Empty(t, first.stderr)
60+ firstFingerprint := decode[map[string]any](t, run("key", "show", "--json").ok(t).stdout)["fingerprint"].(string)
61+ rotated := run("key", "rotate").ok(t)
62+ assert.Contains(t, rotated.stderr, "replaced key "+firstFingerprint)
63+ assert.NotEqual(t, first.stdout, rotated.stdout)
64+ assert.Contains(t, rotated.stderr, "remotes refuse pushes until this key is added")
65+ assert.Equal(t, rotated.stdout, run("key", "show").ok(t).stdout)
66+ assert.Equal(t, []string{"authorized_key", "created_at", "fingerprint"}, keys(t, run("key", "show", "--json").ok(t).stdout))
67+}
M internal/app/cli_watch.go +14 -0
@@ -31,6 +31,9 @@ type jsonEvent struct {
3131 Comment *jsonComment `json:"comment,omitempty"`
3232 // Mergeability is set for a merge_request_mergeability event.
3333 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34+ // State and FailureClass are set for a mirror_failed event.
35+ State string `json:"state,omitempty"`
36+ FailureClass string `json:"failure_class,omitempty"`
3437 }
3538
3639 type jsonRefUpdate struct {
@@ -55,6 +58,8 @@ const (
5558 eventCommentDeleted = "comment_deleted"
5659 eventCommentResolved = "comment_resolved"
5760 eventMRMergeability = "merge_request_mergeability"
61+ eventMirrorFailed = "mirror_failed"
62+ eventMirrorRecovered = "mirror_recovered"
5863 )
5964
6065 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
100105 case *ermite.Event_MergeRequestMergeability:
101106 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102107 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
108+ case *ermite.Event_MirrorFailed:
109+ out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
110+ out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
111+ case *ermite.Event_MirrorRecovered:
112+ out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
103113 case *ermite.Event_Gap:
104114 out.Type = eventGap
105115 default:
@@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string {
134144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135145 case eventMRMergeability:
136146 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
147+ case eventMirrorFailed:
148+ detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
149+ case eventMirrorRecovered:
150+ detail = e.Repo
137151 case eventGap:
138152 detail = "events were lost, reload what you display"
139153 }
@@ -31,6 +31,9 @@ type jsonEvent struct {
31 Comment *jsonComment `json:"comment,omitempty"`31 Comment *jsonComment `json:"comment,omitempty"`
32 // Mergeability is set for a merge_request_mergeability event.32 // Mergeability is set for a merge_request_mergeability event.
33 Mergeability *jsonMergeability `json:"mergeability,omitempty"`33 Mergeability *jsonMergeability `json:"mergeability,omitempty"`
34+ // State and FailureClass are set for a mirror_failed event.
35+ State string `json:"state,omitempty"`
36+ FailureClass string `json:"failure_class,omitempty"`
34 }37 }
35 38
36 type jsonRefUpdate struct {39 type jsonRefUpdate struct {
@@ -55,6 +58,8 @@ const (
55 eventCommentDeleted = "comment_deleted"58 eventCommentDeleted = "comment_deleted"
56 eventCommentResolved = "comment_resolved"59 eventCommentResolved = "comment_resolved"
57 eventMRMergeability = "merge_request_mergeability"60 eventMRMergeability = "merge_request_mergeability"
61+ eventMirrorFailed = "mirror_failed"
62+ eventMirrorRecovered = "mirror_recovered"
58 )63 )
59 64
60 // toJSONEvent maps an event; ok is false for kinds this client does not know,65 // toJSONEvent maps an event; ok is false for kinds this client does not know,
@@ -100,6 +105,11 @@ func toJSONEvent(ev *ermite.Event, baseURL string) (jsonEvent, bool) {
100 case *ermite.Event_MergeRequestMergeability:105 case *ermite.Event_MergeRequestMergeability:
101 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()106 out.Type, out.Repo, out.Number = eventMRMergeability, k.MergeRequestMergeability.GetRepo(), k.MergeRequestMergeability.GetNumber()
102 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())107 out.Mergeability = toJSONMergeability(k.MergeRequestMergeability.GetMergeability())
108+ case *ermite.Event_MirrorFailed:
109+ out.Type, out.Repo = eventMirrorFailed, k.MirrorFailed.GetRepo()
110+ out.State, out.FailureClass = mirrorStateName(k.MirrorFailed.GetState()), k.MirrorFailed.GetFailureClass()
111+ case *ermite.Event_MirrorRecovered:
112+ out.Type, out.Repo = eventMirrorRecovered, k.MirrorRecovered.GetRepo()
103 case *ermite.Event_Gap:113 case *ermite.Event_Gap:
104 out.Type = eventGap114 out.Type = eventGap
105 default:115 default:
@@ -134,6 +144,10 @@ func eventLine(e jsonEvent) string {
134 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)144 detail = fmt.Sprintf("%s!%d by %s", e.Repo, e.Number, e.Comment.Author)
135 case eventMRMergeability:145 case eventMRMergeability:
136 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))146 detail = fmt.Sprintf("%s!%d mergeability: %s", e.Repo, e.Number, mergeabilityEventText(e.Mergeability))
147+ case eventMirrorFailed:
148+ detail = e.Repo + " " + e.State + " (" + e.FailureClass + ")"
149+ case eventMirrorRecovered:
150+ detail = e.Repo
137 case eventGap:151 case eventGap:
138 detail = "events were lost, reload what you display"152 detail = "events were lost, reload what you display"
139 }153 }
M internal/app/cli_watch_test.go +17 -0
@@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) {
266266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
267267 }
268268 }
269+
270+func TestMirrorWatchEvents(t *testing.T) {
271+ failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "")
272+ require.True(t, ok)
273+ assert.Equal(t, "mirror_failed", failed.Type)
274+ assert.Equal(t, "alpha", failed.Repo)
275+ assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)")
276+ raw, err := json.Marshal(failed)
277+ require.NoError(t, err)
278+ assert.Contains(t, string(raw), `"state":"paused"`)
279+ assert.Contains(t, string(raw), `"failure_class":"diverged"`)
280+ recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "")
281+ require.True(t, ok)
282+ assert.Equal(t, "mirror_recovered", recovered.Type)
283+ assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284+ assert.NotContains(t, eventLine(recovered), " \n")
285+}
@@ -266,3 +266,20 @@ func TestNoteWatchEvents(t *testing.T) {
266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)266 assert.Contains(t, string(raw), `"edited_at":"2026-10-10T12:00:00Z"`)
267 }267 }
268 }268 }
269+
270+func TestMirrorWatchEvents(t *testing.T) {
271+ failed, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorFailed{MirrorFailed: &ermite.MirrorFailed{Repo: "alpha", State: ermite.MirrorState_MIRROR_STATE_PAUSED, FailureClass: "diverged"}}}, "")
272+ require.True(t, ok)
273+ assert.Equal(t, "mirror_failed", failed.Type)
274+ assert.Equal(t, "alpha", failed.Repo)
275+ assert.Contains(t, eventLine(failed), "mirror_failed alpha paused (diverged)")
276+ raw, err := json.Marshal(failed)
277+ require.NoError(t, err)
278+ assert.Contains(t, string(raw), `"state":"paused"`)
279+ assert.Contains(t, string(raw), `"failure_class":"diverged"`)
280+ recovered, ok := toJSONEvent(&ermite.Event{Kind: &ermite.Event_MirrorRecovered{MirrorRecovered: &ermite.MirrorRecovered{Repo: "alpha"}}}, "")
281+ require.True(t, ok)
282+ assert.Equal(t, "mirror_recovered", recovered.Type)
283+ assert.Contains(t, eventLine(recovered), "mirror_recovered alpha")
284+ assert.NotContains(t, eventLine(recovered), " \n")
285+}
M internal/app/forge.go +33 -1
@@ -26,6 +26,7 @@ import (
2626 "bnema.dev/ermite/internal/adapters/githttp"
2727 "bnema.dev/ermite/internal/adapters/githubapi"
2828 "bnema.dev/ermite/internal/adapters/githubraw"
29+ "bnema.dev/ermite/internal/adapters/gitpush"
2930 "bnema.dev/ermite/internal/adapters/gitremote"
3031 "bnema.dev/ermite/internal/adapters/httpserver"
3132 "bnema.dev/ermite/internal/adapters/kvstore"
@@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes
4950
5051 // options are the parts of the wiring that tests replace.
5152 type options struct {
53+ // pusher and mirrors override mirror transport/settings in local tests only.
54+ pusher ports.MirrorPusher
55+ mirrors func(*usecase.MirrorsConfig)
5256 // hookExe is the executable the hook scripts exec; empty means this
5357 // process's own executable.
5458 hookExe string
@@ -151,6 +155,8 @@ type forge struct {
151155 CI *usecase.CI
152156 // Settings are the secrets, variables and environments of CI.
153157 Settings *usecase.CISettings
158+ // Mirrors exposes Warning and Badge for the hook server and web composition.
159+ Mirrors *usecase.Mirrors
154160 // OIDC is the provider of ID tokens, disabled without a secrets key.
155161 OIDC *usecase.OIDC
156162 API *rpc.API
@@ -274,6 +280,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
274280 }
275281 sealer = box
276282 }
283+ pusher := opts.pusher
284+ if pusher == nil {
285+ pusher = gitpush.New(gitpush.Config{
286+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287+ KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288+ TempDir: filepath.Join(cfg.DataDir, "run", "mirror"),
289+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290+ })
291+ }
292+ mirrorCfg := usecase.MirrorsConfig{
293+ Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294+ Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295+ }
296+ if opts.mirrors != nil {
297+ opts.mirrors(&mirrorCfg)
298+ }
299+ f.Mirrors = usecase.NewMirrors(mirrorCfg)
300+ f.Pushes.WithMirror(f.Mirrors)
301+ f.Repos.WithMirror(f.Mirrors)
302+ f.hooks.WithMirrorWarner(f.Mirrors)
303+
277304 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
278305 if err != nil {
279306 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
@@ -342,7 +369,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
342369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
343370 })
344371 f.Mergeability.Start()
345- f.MergeRequests.WithVerifier(f.Signatures)
372+ f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
346373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
347374 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
348375 })
@@ -400,6 +427,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
400427 }
401428 f.API = rpc.New(rpc.Config{
402429 Auth: f.Auth,
430+ Mirrors: f.Mirrors,
403431 Repos: f.Repos,
404432 Tokens: f.Tokens,
405433 Browse: f.Browse,
@@ -489,6 +517,7 @@ func (f *forge) Run(ctx context.Context) error {
489517 // outlives the HTTP server: requests still in flight while it drains are
490518 // pushes whose hooks need it. A failure of either stops both.
491519 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
520+ f.Mirrors.Start()
492521 ctx, cancel := context.WithCancel(ctx)
493522 defer cancel()
494523 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
@@ -517,6 +546,9 @@ func (f *forge) Close(ctx context.Context) {
517546 if f.MergeRequests != nil {
518547 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
519548 }
549+ if f.Mirrors != nil {
550+ f.Mirrors.Close(ctx)
551+ }
520552 if f.CI != nil {
521553 f.CI.Close(ctx)
522554 }
@@ -26,6 +26,7 @@ import (
26 "bnema.dev/ermite/internal/adapters/githttp"26 "bnema.dev/ermite/internal/adapters/githttp"
27 "bnema.dev/ermite/internal/adapters/githubapi"27 "bnema.dev/ermite/internal/adapters/githubapi"
28 "bnema.dev/ermite/internal/adapters/githubraw"28 "bnema.dev/ermite/internal/adapters/githubraw"
29+ "bnema.dev/ermite/internal/adapters/gitpush"
29 "bnema.dev/ermite/internal/adapters/gitremote"30 "bnema.dev/ermite/internal/adapters/gitremote"
30 "bnema.dev/ermite/internal/adapters/httpserver"31 "bnema.dev/ermite/internal/adapters/httpserver"
31 "bnema.dev/ermite/internal/adapters/kvstore"32 "bnema.dev/ermite/internal/adapters/kvstore"
@@ -49,6 +50,9 @@ const maxReceiveBytes = githttp.DefaultMaxBodyBytes
49 50
50 // options are the parts of the wiring that tests replace.51 // options are the parts of the wiring that tests replace.
51 type options struct {52 type options struct {
53+ // pusher and mirrors override mirror transport/settings in local tests only.
54+ pusher ports.MirrorPusher
55+ mirrors func(*usecase.MirrorsConfig)
52 // hookExe is the executable the hook scripts exec; empty means this56 // hookExe is the executable the hook scripts exec; empty means this
53 // process's own executable.57 // process's own executable.
54 hookExe string58 hookExe string
@@ -151,6 +155,8 @@ type forge struct {
151 CI *usecase.CI155 CI *usecase.CI
152 // Settings are the secrets, variables and environments of CI.156 // Settings are the secrets, variables and environments of CI.
153 Settings *usecase.CISettings157 Settings *usecase.CISettings
158+ // Mirrors exposes Warning and Badge for the hook server and web composition.
159+ Mirrors *usecase.Mirrors
154 // OIDC is the provider of ID tokens, disabled without a secrets key.160 // OIDC is the provider of ID tokens, disabled without a secrets key.
155 OIDC *usecase.OIDC161 OIDC *usecase.OIDC
156 API *rpc.API162 API *rpc.API
@@ -274,6 +280,27 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
274 }280 }
275 sealer = box281 sealer = box
276 }282 }
283+ pusher := opts.pusher
284+ if pusher == nil {
285+ pusher = gitpush.New(gitpush.Config{
286+ Bin: cfg.Git, Path: path, Home: filepath.Join(cfg.DataDir, "run", "git-home"),
287+ KnownHosts: filepath.Join(cfg.DataDir, "mirror_known_hosts"),
288+ TempDir: filepath.Join(cfg.DataDir, "run", "mirror"),
289+ RepoDir: func(name domain.RepoName) string { return gitcli.RepoDir(cfg.DataDir, name) },
290+ })
291+ }
292+ mirrorCfg := usecase.MirrorsConfig{
293+ Store: store, Keys: store, Sealer: sealer, Pusher: pusher,
294+ Meta: store, Git: git, Reader: reader, Bus: f.Bus, Clock: clock, PublicURL: cfg.PublicURL,
295+ }
296+ if opts.mirrors != nil {
297+ opts.mirrors(&mirrorCfg)
298+ }
299+ f.Mirrors = usecase.NewMirrors(mirrorCfg)
300+ f.Pushes.WithMirror(f.Mirrors)
301+ f.Repos.WithMirror(f.Mirrors)
302+ f.hooks.WithMirrorWarner(f.Mirrors)
303+
277 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)304 f.Settings, err = usecase.NewCISettings(ctx, store, store.Settings(), sealer, clock)
278 if err != nil {305 if err != nil {
279 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)306 return nil, fmt.Errorf("%s: %w", config.EnvSecretsKey, err)
@@ -342,7 +369,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
342 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,369 CI: f.CI, Checks: f.CI, PullRequests: f.CI, RefTips: store.CI(), Mergeability: f.Mergeability,
343 })370 })
344 f.Mergeability.Start()371 f.Mergeability.Start()
345- f.MergeRequests.WithVerifier(f.Signatures)372+ f.MergeRequests.WithVerifier(f.Signatures).WithMirror(f.Mirrors)
346 f.Issues = usecase.NewIssues(usecase.IssuesConfig{373 f.Issues = usecase.NewIssues(usecase.IssuesConfig{
347 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,374 Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: f.Bus, Clock: clock,
348 })375 })
@@ -400,6 +427,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
400 }427 }
401 f.API = rpc.New(rpc.Config{428 f.API = rpc.New(rpc.Config{
402 Auth: f.Auth,429 Auth: f.Auth,
430+ Mirrors: f.Mirrors,
403 Repos: f.Repos,431 Repos: f.Repos,
404 Tokens: f.Tokens,432 Tokens: f.Tokens,
405 Browse: f.Browse,433 Browse: f.Browse,
@@ -489,6 +517,7 @@ func (f *forge) Run(ctx context.Context) error {
489 // outlives the HTTP server: requests still in flight while it drains are517 // outlives the HTTP server: requests still in flight while it drains are
490 // pushes whose hooks need it. A failure of either stops both.518 // pushes whose hooks need it. A failure of either stops both.
491 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {519 func (f *forge) Serve(ctx context.Context, ln net.Listener) error {
520+ f.Mirrors.Start()
492 ctx, cancel := context.WithCancel(ctx)521 ctx, cancel := context.WithCancel(ctx)
493 defer cancel()522 defer cancel()
494 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))523 hookCtx, stopHooks := context.WithCancel(context.WithoutCancel(ctx))
@@ -517,6 +546,9 @@ func (f *forge) Close(ctx context.Context) {
517 if f.MergeRequests != nil {546 if f.MergeRequests != nil {
518 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability547 f.MergeRequests.Wait(ctx) // its side effects use CI and Mergeability
519 }548 }
549+ if f.Mirrors != nil {
550+ f.Mirrors.Close(ctx)
551+ }
520 if f.CI != nil {552 if f.CI != nil {
521 f.CI.Close(ctx)553 f.CI.Close(ctx)
522 }554 }
M internal/app/root.go +1 -1
@@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
3030 }
3131 root.AddCommand(
3232 newServeCmd(), newHookCmd(),
33- newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
33+ newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
3434 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
3535 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
3636 )
@@ -30,7 +30,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
30 }30 }
31 root.AddCommand(31 root.AddCommand(
32 newServeCmd(), newHookCmd(),32 newServeCmd(), newHookCmd(),
33- newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),33+ newMirrorCmd(env), newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
34 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),34 newMRCmd(env), newIssueCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),35 newRunCmd(env), newWorkflowCmd(env), newRunnerCmd(env), newSecretCmd(env), newVariableCmd(env), newEnvironmentCmd(env), newOIDCCmd(env),
36 )36 )
M internal/app/web.go +1 -0
@@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
5858 Runs: f.CI,
5959 Artifacts: artifacts,
6060 Events: f.EventLog,
61+ Mirrors: f.Mirrors,
6162 Owner: cfg.Owner,
6263 PublicURL: cfg.PublicURL,
6364 SourceURL: cfg.SourceURL,
@@ -58,6 +58,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
58 Runs: f.CI,58 Runs: f.CI,
59 Artifacts: artifacts,59 Artifacts: artifacts,
60 Events: f.EventLog,60 Events: f.EventLog,
61+ Mirrors: f.Mirrors,
61 Owner: cfg.Owner,62 Owner: cfg.Owner,
62 PublicURL: cfg.PublicURL,63 PublicURL: cfg.PublicURL,
63 SourceURL: cfg.SourceURL,64 SourceURL: cfg.SourceURL,
M internal/domain/event.go +22 -0
@@ -304,3 +304,25 @@ type EventGap struct{}
304304
305305 // EventKind implements Event.
306306 func (EventGap) EventKind() string { return "gap" }
307+
308+// MirrorFailed is published when a mirror enters failing or paused (transitions only).
309+type MirrorFailed struct {
310+ Repo RepoName
311+ State MirrorState
312+ Class MirrorFailureClass
313+ At time.Time
314+ Visibility Visibility
315+ CreatedAt time.Time
316+}
317+
318+func (MirrorFailed) EventKind() string { return "mirror.failed" }
319+
320+// MirrorRecovered is published when a failing or paused mirror succeeds again.
321+type MirrorRecovered struct {
322+ Repo RepoName
323+ At time.Time
324+ Visibility Visibility
325+ CreatedAt time.Time
326+}
327+
328+func (MirrorRecovered) EventKind() string { return "mirror.recovered" }
@@ -304,3 +304,25 @@ type EventGap struct{}
304 304
305 // EventKind implements Event.305 // EventKind implements Event.
306 func (EventGap) EventKind() string { return "gap" }306 func (EventGap) EventKind() string { return "gap" }
307+
308+// MirrorFailed is published when a mirror enters failing or paused (transitions only).
309+type MirrorFailed struct {
310+ Repo RepoName
311+ State MirrorState
312+ Class MirrorFailureClass
313+ At time.Time
314+ Visibility Visibility
315+ CreatedAt time.Time
316+}
317+
318+func (MirrorFailed) EventKind() string { return "mirror.failed" }
319+
320+// MirrorRecovered is published when a failing or paused mirror succeeds again.
321+type MirrorRecovered struct {
322+ Repo RepoName
323+ At time.Time
324+ Visibility Visibility
325+ CreatedAt time.Time
326+}
327+
328+func (MirrorRecovered) EventKind() string { return "mirror.recovered" }
A internal/domain/mirror.go +254 -0
@@ -0,0 +1,254 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "fmt"
7+ "net"
8+ "net/url"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode"
13+)
14+
15+const (
16+ MirrorMinInterval = 5 * time.Minute // densest cron allowed
17+ MirrorManualInterval = 60 * time.Second // min gap between manual syncs (not --force)
18+ MirrorRepoPlaceholder = "{repo}"
19+)
20+
21+// MirrorGlobalConfig is the server-wide mirror policy.
22+type MirrorGlobalConfig struct {
23+ Enabled bool
24+ URLTemplate string // ssh URL containing MirrorRepoPlaceholder; "" allowed while disabled
25+ Cron string // 5-field cron, UTC; "" allowed while disabled
26+ AllBranches bool
27+ UpdatedAt time.Time
28+}
29+
30+// MirrorRepoConfig is the per-repository entry: an override (URL != "") or an exclusion.
31+type MirrorRepoConfig struct {
32+ Repo RepoName
33+ URL string // "" means no override
34+ Cron string // "" means inherit global cron
35+ AllBranches *bool // nil means inherit
36+ Excluded bool // opt out of the global template; ignored when URL != ""
37+ UpdatedAt time.Time
38+}
39+type MirrorSource string
40+
41+const (
42+ MirrorSourceNone MirrorSource = "disabled" // nothing applies
43+ MirrorSourceGlobal MirrorSource = "global"
44+ MirrorSourceOverride MirrorSource = "override"
45+ MirrorSourceExcluded MirrorSource = "excluded"
46+)
47+
48+// MirrorTarget is what one repository is mirrored to after resolution.
49+type MirrorTarget struct {
50+ Repo RepoName
51+ Enabled bool
52+ URL string
53+ Cron Cron // zero when !Enabled
54+ CronSpec string
55+ AllBranches bool
56+ Source MirrorSource
57+}
58+
59+func mirrorURLParts(s string) (host, path string, err error) {
60+ bad := func() (string, string, error) {
61+ return "", "", fmt.Errorf("%w: mirror url must be an SSH host and repository path without credentials", ErrInvalid)
62+ }
63+ if strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) {
64+ return bad()
65+ }
66+ if strings.HasPrefix(s, "ssh://") {
67+ u, e := url.Parse(s)
68+ if e != nil || u.Hostname() == "" || u.RawQuery != "" || u.Fragment != "" {
69+ return bad()
70+ }
71+ if u.User != nil {
72+ if _, ok := u.User.Password(); ok {
73+ return bad()
74+ }
75+ if u.User.Username() == "" || strings.HasPrefix(u.User.Username(), "-") {
76+ return bad()
77+ }
78+ }
79+ if p := u.Port(); p != "" {
80+ n, e := strconv.Atoi(p)
81+ if e != nil || n < 1 || n > 65535 {
82+ return bad()
83+ }
84+ }
85+ host = u.Host
86+ path = strings.TrimPrefix(u.Path, "/")
87+ } else {
88+ if strings.Contains(s, "://") {
89+ return bad()
90+ }
91+ var ok bool
92+ host, path, ok = strings.Cut(s, ":")
93+ if !ok {
94+ return bad()
95+ }
96+ if strings.Contains(host, "@") {
97+ user, h, _ := strings.Cut(host, "@")
98+ if user == "" || strings.HasPrefix(user, "-") || strings.ContainsAny(user, ":/@") {
99+ return bad()
100+ }
101+ host = h
102+ }
103+ if strings.ContainsAny(host, "/@:") {
104+ return bad()
105+ }
106+ }
107+ if host == "" || strings.HasPrefix(host, "-") || path == "" || strings.HasPrefix(path, "-") || strings.Contains(path, "@") {
108+ return bad()
109+ }
110+ // Reject malformed bracketed addresses as well as empty hosts.
111+ if strings.HasPrefix(host, "[") {
112+ h := strings.TrimSuffix(strings.TrimPrefix(host, "["), "]")
113+ if x, _, e := net.SplitHostPort(host); e == nil {
114+ h = strings.Trim(x, "[]")
115+ }
116+ if net.ParseIP(h) == nil {
117+ return bad()
118+ }
119+ }
120+ return host, path, nil
121+}
122+
123+// ValidateMirrorURL accepts "git@host:path" (scp-like, user optional, no ':' port) and
124+// "ssh://[user@]host[:port]/path". Anything else, an empty host, control chars, or a path
125+// starting with "-" is "%w: mirror url ..." with ErrInvalid. URL is never logged in full
126+// elsewhere: use RedactMirrorURL.
127+func ValidateMirrorURL(s string) error { _, _, err := mirrorURLParts(s); return err }
128+func RedactMirrorURL(s string) string {
129+ h, p, e := mirrorURLParts(s)
130+ if e != nil {
131+ return "[invalid mirror URL]"
132+ }
133+ return h + ":" + p
134+}
135+
136+// ExpandMirrorURL replaces MirrorRepoPlaceholder with repo (as a path segment, not escaped
137+// beyond RepoName's own charset). Returns ErrInvalid when the template has no placeholder.
138+func ExpandMirrorURL(template string, repo RepoName) (string, error) {
139+ if !strings.Contains(template, MirrorRepoPlaceholder) {
140+ return "", fmt.Errorf("%w: mirror url template needs {repo}", ErrInvalid)
141+ }
142+ return strings.ReplaceAll(template, MirrorRepoPlaceholder, repo.String()), nil
143+}
144+func mirrorCron(spec string) (Cron, error) {
145+ c, e := ParseCron(spec)
146+ if e != nil {
147+ return c, e
148+ }
149+ if gap := CronMinGap(c); gap > 0 && gap < MirrorMinInterval {
150+ return Cron{}, fmt.Errorf("%w: cron interval must be at least five minutes", ErrInvalid)
151+ }
152+ return c, nil
153+}
154+
155+// ValidateMirrorGlobalConfig / ValidateMirrorRepoConfig return InvalidFieldsError with fields
156+// "url_template", "cron", "url": cron parsed with ParseCron and checked by CronMinGap >= MirrorMinInterval;
157+// url_template must contain the placeholder; when Enabled both are required.
158+func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {
159+ return ValidateMirrorGlobalConfigWith(g, ValidateMirrorURL)
160+}
161+
162+// ValidateMirrorGlobalConfigWith is ValidateMirrorGlobalConfig with another URL check, for
163+// deployments and tests that accept more than SSH URLs.
164+func ValidateMirrorGlobalConfigWith(g MirrorGlobalConfig, validateURL func(string) error) error {
165+ var fields []FieldViolation
166+ if g.Enabled || g.URLTemplate != "" {
167+ repo, _ := ParseRepoName("repo")
168+ u, e := ExpandMirrorURL(g.URLTemplate, repo)
169+ if e == nil {
170+ e = validateURL(u)
171+ }
172+ if e != nil {
173+ fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})
174+ }
175+ }
176+ if g.Enabled || g.Cron != "" {
177+ if _, e := mirrorCron(g.Cron); e != nil {
178+ fields = append(fields, FieldViolation{"cron", e.Error()})
179+ }
180+ }
181+ if len(fields) > 0 {
182+ return &InvalidFieldsError{Fields: fields}
183+ }
184+ return nil
185+}
186+func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {
187+ return ValidateMirrorRepoConfigWith(r, ValidateMirrorURL)
188+}
189+
190+// ValidateMirrorRepoConfigWith is ValidateMirrorRepoConfig with another URL check.
191+func ValidateMirrorRepoConfigWith(r MirrorRepoConfig, validateURL func(string) error) error {
192+ var fields []FieldViolation
193+ if r.URL != "" {
194+ if e := validateURL(r.URL); e != nil {
195+ fields = append(fields, FieldViolation{"url", e.Error()})
196+ }
197+ }
198+ if r.Cron != "" {
199+ if _, e := mirrorCron(r.Cron); e != nil {
200+ fields = append(fields, FieldViolation{"cron", e.Error()})
201+ }
202+ }
203+ if len(fields) > 0 {
204+ return &InvalidFieldsError{Fields: fields}
205+ }
206+ return nil
207+}
208+
209+// ResolveMirror: override wins even when global is disabled; else global, unless excluded.
210+func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (MirrorTarget, error) {
211+ return ResolveMirrorWith(g, r, repo, ValidateMirrorURL)
212+}
213+
214+// ResolveMirrorWith is ResolveMirror with another URL check.
215+func ResolveMirrorWith(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName, validateURL func(string) error) (MirrorTarget, error) {
216+ t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}
217+ if r != nil && r.URL != "" {
218+ t.URL = r.URL
219+ t.Source = MirrorSourceOverride
220+ } else if r != nil && r.Excluded {
221+ t.Source = MirrorSourceExcluded
222+ return t, nil
223+ } else if g.Enabled {
224+ var e error
225+ t.URL, e = ExpandMirrorURL(g.URLTemplate, repo)
226+ if e != nil {
227+ return t, e
228+ }
229+ t.Source = MirrorSourceGlobal
230+ } else {
231+ return t, nil
232+ }
233+ t.CronSpec = g.Cron
234+ t.AllBranches = g.AllBranches
235+ if r != nil {
236+ if r.Cron != "" {
237+ t.CronSpec = r.Cron
238+ }
239+ if r.AllBranches != nil {
240+ t.AllBranches = *r.AllBranches
241+ }
242+ }
243+ if e := validateURL(t.URL); e != nil {
244+ return t, e
245+ }
246+ // Mutation boundaries validate the minimum gap; resolution only parses.
247+ c, e := ParseCron(t.CronSpec)
248+ if e != nil {
249+ return t, e
250+ }
251+ t.Cron = c
252+ t.Enabled = true
253+ return t, nil
254+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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+}
@@ -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
A internal/ports/mocks/MirrorTrigger.go +100 -0
A internal/ports/mocks/MirrorWarner.go +183 -0
M internal/usecase/events.go +8 -0
M internal/usecase/mergerequests.go +10 -0
A internal/usecase/mirrors.go +91 -0
A internal/usecase/mirrors_config.go +365 -0
A internal/usecase/mirrors_config_test.go +276 -0
A internal/usecase/mirrors_fixture_test.go +266 -0
A internal/usecase/mirrors_key.go +131 -0
A internal/usecase/mirrors_key_fixture_test.go +52 -0
A internal/usecase/mirrors_notify_test.go +112 -0
A internal/usecase/mirrors_schedule.go +154 -0
A internal/usecase/mirrors_sync.go +299 -0
A internal/usecase/mirrors_sync_test.go +492 -0
M internal/usecase/pushes.go +10 -0
M internal/usecase/repos.go +13 -5
M internal/usecase/repos_write.go +3 -0
A proto/ermite/storage/mirror.proto +45 -0

Comments0 open / 0

no comments yet.

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close