detached 5e1949f

fix(domain): reject dash-led SSH users and hosts, check cron gaps on non-leap years and inject the URL validator

commit
5e1949f5e44f48fbc18ada70610846609e24bb85
author
brice · · 2026-10-20 07:06 UTC
committer
brice · · 2026-10-20 10:02 UTC
parent
335d134
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +165 -89
M internal/domain/event.go +0 -1
@@ -310,7 +310,6 @@ type MirrorFailed struct {
310310 Repo RepoName
311311 State MirrorState
312312 Class MirrorFailureClass
313- Reason string
314313 At time.Time
315314 Visibility Visibility
316315 CreatedAt time.Time
@@ -310,7 +310,6 @@ type MirrorFailed struct {
310 Repo RepoName310 Repo RepoName
311 State MirrorState311 State MirrorState
312 Class MirrorFailureClass312 Class MirrorFailureClass
313- Reason string
314 At time.Time313 At time.Time
315 Visibility Visibility314 Visibility Visibility
316 CreatedAt time.Time315 CreatedAt time.Time
M internal/domain/mirror.go +21 -5
@@ -72,7 +72,7 @@ func mirrorURLParts(s string) (host, path string, err error) {
7272 if _, ok := u.User.Password(); ok {
7373 return bad()
7474 }
75- if u.User.Username() == "" {
75+ if u.User.Username() == "" || strings.HasPrefix(u.User.Username(), "-") {
7676 return bad()
7777 }
7878 }
@@ -95,7 +95,7 @@ func mirrorURLParts(s string) (host, path string, err error) {
9595 }
9696 if strings.Contains(host, "@") {
9797 user, h, _ := strings.Cut(host, "@")
98- if user == "" || strings.ContainsAny(user, ":/@") {
98+ if user == "" || strings.HasPrefix(user, "-") || strings.ContainsAny(user, ":/@") {
9999 return bad()
100100 }
101101 host = h
@@ -156,12 +156,18 @@ func mirrorCron(spec string) (Cron, error) {
156156 // "url_template", "cron", "url": cron parsed with ParseCron and checked by CronMinGap >= MirrorMinInterval;
157157 // url_template must contain the placeholder; when Enabled both are required.
158158 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 {
159165 var fields []FieldViolation
160166 if g.Enabled || g.URLTemplate != "" {
161167 repo, _ := ParseRepoName("repo")
162168 u, e := ExpandMirrorURL(g.URLTemplate, repo)
163169 if e == nil {
164- e = ValidateMirrorURL(u)
170+ e = validateURL(u)
165171 }
166172 if e != nil {
167173 fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})
@@ -178,9 +184,14 @@ func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {
178184 return nil
179185 }
180186 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 {
181192 var fields []FieldViolation
182193 if r.URL != "" {
183- if e := ValidateMirrorURL(r.URL); e != nil {
194+ if e := validateURL(r.URL); e != nil {
184195 fields = append(fields, FieldViolation{"url", e.Error()})
185196 }
186197 }
@@ -197,6 +208,11 @@ func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {
197208
198209 // ResolveMirror: override wins even when global is disabled; else global, unless excluded.
199210 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) {
200216 t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}
201217 if r != nil && r.URL != "" {
202218 t.URL = r.URL
@@ -224,7 +240,7 @@ func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (Mi
224240 t.AllBranches = *r.AllBranches
225241 }
226242 }
227- if e := ValidateMirrorURL(t.URL); e != nil {
243+ if e := validateURL(t.URL); e != nil {
228244 return t, e
229245 }
230246 // Mutation boundaries validate the minimum gap; resolution only parses.
@@ -72,7 +72,7 @@ func mirrorURLParts(s string) (host, path string, err error) {
72 if _, ok := u.User.Password(); ok {72 if _, ok := u.User.Password(); ok {
73 return bad()73 return bad()
74 }74 }
75- if u.User.Username() == "" {75+ if u.User.Username() == "" || strings.HasPrefix(u.User.Username(), "-") {
76 return bad()76 return bad()
77 }77 }
78 }78 }
@@ -95,7 +95,7 @@ func mirrorURLParts(s string) (host, path string, err error) {
95 }95 }
96 if strings.Contains(host, "@") {96 if strings.Contains(host, "@") {
97 user, h, _ := strings.Cut(host, "@")97 user, h, _ := strings.Cut(host, "@")
98- if user == "" || strings.ContainsAny(user, ":/@") {98+ if user == "" || strings.HasPrefix(user, "-") || strings.ContainsAny(user, ":/@") {
99 return bad()99 return bad()
100 }100 }
101 host = h101 host = h
@@ -156,12 +156,18 @@ func mirrorCron(spec string) (Cron, error) {
156 // "url_template", "cron", "url": cron parsed with ParseCron and checked by CronMinGap >= MirrorMinInterval;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.157 // url_template must contain the placeholder; when Enabled both are required.
158 func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {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 {
159 var fields []FieldViolation165 var fields []FieldViolation
160 if g.Enabled || g.URLTemplate != "" {166 if g.Enabled || g.URLTemplate != "" {
161 repo, _ := ParseRepoName("repo")167 repo, _ := ParseRepoName("repo")
162 u, e := ExpandMirrorURL(g.URLTemplate, repo)168 u, e := ExpandMirrorURL(g.URLTemplate, repo)
163 if e == nil {169 if e == nil {
164- e = ValidateMirrorURL(u)170+ e = validateURL(u)
165 }171 }
166 if e != nil {172 if e != nil {
167 fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})173 fields = append(fields, FieldViolation{"url_template", "must be an SSH URL template containing {repo}"})
@@ -178,9 +184,14 @@ func ValidateMirrorGlobalConfig(g MirrorGlobalConfig) error {
178 return nil184 return nil
179 }185 }
180 func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {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 {
181 var fields []FieldViolation192 var fields []FieldViolation
182 if r.URL != "" {193 if r.URL != "" {
183- if e := ValidateMirrorURL(r.URL); e != nil {194+ if e := validateURL(r.URL); e != nil {
184 fields = append(fields, FieldViolation{"url", e.Error()})195 fields = append(fields, FieldViolation{"url", e.Error()})
185 }196 }
186 }197 }
@@ -197,6 +208,11 @@ func ValidateMirrorRepoConfig(r MirrorRepoConfig) error {
197 208
198 // ResolveMirror: override wins even when global is disabled; else global, unless excluded.209 // ResolveMirror: override wins even when global is disabled; else global, unless excluded.
199 func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (MirrorTarget, error) {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) {
200 t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}216 t := MirrorTarget{Repo: repo, Source: MirrorSourceNone}
201 if r != nil && r.URL != "" {217 if r != nil && r.URL != "" {
202 t.URL = r.URL218 t.URL = r.URL
@@ -224,7 +240,7 @@ func ResolveMirror(g MirrorGlobalConfig, r *MirrorRepoConfig, repo RepoName) (Mi
224 t.AllBranches = *r.AllBranches240 t.AllBranches = *r.AllBranches
225 }241 }
226 }242 }
227- if e := ValidateMirrorURL(t.URL); e != nil {243+ if e := validateURL(t.URL); e != nil {
228 return t, e244 return t, e
229 }245 }
230 // Mutation boundaries validate the minimum gap; resolution only parses.246 // Mutation boundaries validate the minimum gap; resolution only parses.
M internal/domain/mirror_cron.go +63 -5
@@ -1,15 +1,49 @@
11 // SPDX-License-Identifier: AGPL-3.0-or-later
22 package domain
33
4-import "time"
4+import (
5+ "sync"
6+ "time"
7+)
58
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.
620 func CronMinGap(c Cron) time.Duration {
7- start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
8- // Include the next January so adjacent December/January runs are compared.
9- end := start.AddDate(1, 1, 0)
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
1040 var prev time.Time
1141 var gap time.Duration
1242 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+ }
1347 if c.Matches(t) {
1448 if !prev.IsZero() {
1549 d := t.Sub(prev)
@@ -22,9 +56,33 @@ func CronMinGap(c Cron) time.Duration {
2256 }
2357 return gap
2458 }
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.
2578 func NextCron(s CronSchedule, t time.Time) (time.Time, bool) {
26- end := t.Add(400 * 24 * time.Hour)
79+ end := t.Add(nextCronHorizon)
80+ utc := s.Loc == nil || s.Loc == time.UTC
2781 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+ }
2886 if s.Due(n) {
2987 return n, true
3088 }
@@ -1,15 +1,49 @@
1 // SPDX-License-Identifier: AGPL-3.0-or-later1 // SPDX-License-Identifier: AGPL-3.0-or-later
2 package domain2 package domain
3 3
4-import "time"4+import (
5+ "sync"
6+ "time"
7+)
5 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.
6 func CronMinGap(c Cron) time.Duration {20 func CronMinGap(c Cron) time.Duration {
7- start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)21+ cronGapCache.Lock()
8- // Include the next January so adjacent December/January runs are compared.22+ gap, ok := cronGapCache.m[c]
9- end := start.AddDate(1, 1, 0)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
10 var prev time.Time40 var prev time.Time
11 var gap time.Duration41 var gap time.Duration
12 for t := start; t.Before(end); t = t.Add(time.Minute) {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+ }
13 if c.Matches(t) {47 if c.Matches(t) {
14 if !prev.IsZero() {48 if !prev.IsZero() {
15 d := t.Sub(prev)49 d := t.Sub(prev)
@@ -22,9 +56,33 @@ func CronMinGap(c Cron) time.Duration {
22 }56 }
23 return gap57 return gap
24 }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.
25 func NextCron(s CronSchedule, t time.Time) (time.Time, bool) {78 func NextCron(s CronSchedule, t time.Time) (time.Time, bool) {
26- end := t.Add(400 * 24 * time.Hour)79+ end := t.Add(nextCronHorizon)
80+ utc := s.Loc == nil || s.Loc == time.UTC
27 for n := t.Truncate(time.Minute).Add(time.Minute); !n.After(end); n = n.Add(time.Minute) {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+ }
28 if s.Due(n) {86 if s.Due(n) {
29 return n, true87 return n, true
30 }88 }
M internal/domain/mirror_test.go +42 -55
@@ -19,6 +19,7 @@ func TestMirrorURL(t *testing.T) {
1919 }{
2020 {"git@host:acme/repo", true}, {"host:repo", true}, {"ssh://git@host:2222/acme/repo", true}, {"ssh://[::1]/repo", true},
2121 {"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},
2223 } {
2324 t.Run(tt.s, func(t *testing.T) {
2425 e := ValidateMirrorURL(tt.s)
@@ -79,7 +80,11 @@ func TestMirrorCronValidation(t *testing.T) {
7980 spec string
8081 gap time.Duration
8182 valid bool
82- }{{"*/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}} {
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+ } {
8388 t.Run(tt.spec, func(t *testing.T) {
8489 c, e := ParseCron(tt.spec)
8590 if e == nil && CronMinGap(c) != tt.gap {
@@ -125,6 +130,22 @@ func TestMirrorNextCron(t *testing.T) {
125130 if _, ok := NextCron(CronSchedule{}, start); ok {
126131 t.Fatal("empty cron fired")
127132 }
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+ }
128149 }
129150 func TestMirrorTransitions(t *testing.T) {
130151 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -192,15 +213,31 @@ func TestMirrorClassify(t *testing.T) {
192213 for _, tt := range []struct {
193214 s string
194215 c MirrorFailureClass
195- }{{"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}} {
196- if c := ClassifyMirrorOutput(tt.s, errors.New("exit"), false); c != tt.c {
197- t.Fatal(tt, c)
198- }
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+ })
199225 }
200226 if ClassifyMirrorOutput("", nil, false) != MirrorClassNone || ClassifyMirrorOutput("non-fast-forward", nil, true) != MirrorClassTransient {
201227 t.Fatal("classification")
202228 }
203229 }
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+}
204241 func TestMirrorRefs(t *testing.T) {
205242 id, _ := ParseObjectID(strings.Repeat("a", 40))
206243 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"}}
@@ -253,43 +290,6 @@ func TestMirrorKey(t *testing.T) {
253290 }
254291 }
255292
256-func TestMirrorRecoveryFromFailing(t *testing.T) {
257- now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
258- for _, state := range []MirrorState{MirrorDisabled, MirrorOK, MirrorFailing, MirrorPaused} {
259- for _, noop := range []bool{false, true} {
260- tr := ApplyMirrorOutcome(MirrorStatus{State: state, ConsecutiveFailures: 2, Class: MirrorClassTransient, Detail: "old"}, MirrorOutcome{NoOp: noop}, now)
261- if tr.Recovered != (state == MirrorFailing || state == MirrorPaused) || tr.FailedEvent || tr.Status.ConsecutiveFailures != 0 || tr.Status.Class != "" || tr.Status.Reason != "" || !tr.Status.NextRetryAt.IsZero() {
262- t.Fatal(state, tr)
263- }
264- }
265- }
266-}
267-func TestMirrorNextCronFallBack(t *testing.T) {
268- loc, _ := time.LoadLocation("America/New_York")
269- c, _ := ParseCron("30 1 * * *")
270- // After the first 01:30, the repeated 01:30 must not fire again.
271- start := time.Date(2024, 11, 3, 5, 30, 0, 0, time.UTC)
272- got, ok := NextCron(CronSchedule{Cron: c, Loc: loc}, start)
273- want := time.Date(2024, 11, 4, 1, 30, 0, 0, loc)
274- if !ok || !got.Equal(want) {
275- t.Fatal(got, ok)
276- }
277-}
278-
279-func TestMirrorCronYearBoundary(t *testing.T) {
280- spec := "0,59 0,23 1,31 1,12 *"
281- c, err := ParseCron(spec)
282- if err != nil {
283- t.Fatal(err)
284- }
285- if got := CronMinGap(c); got != time.Minute {
286- t.Fatal(got)
287- }
288- if err := ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: spec}); err == nil {
289- t.Fatal("accepted one-minute year-boundary gap")
290- }
291-}
292-
293293 func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294294 g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295295 repo, _ := ParseRepoName("demo")
@@ -300,16 +300,3 @@ func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
300300 t.Fatal("mutation must still reject short gap")
301301 }
302302 }
303-
304-func TestMirrorClassifyAuthVariants(t *testing.T) {
305- for _, s := range []string{
306- "Permission denied (publickey,password).",
307- "Permission denied (password,publickey,keyboard-interactive).",
308- "ERROR: Permission to x/y.git denied to deploy key",
309- "remote: Permission to x/y.git denied to someone.",
310- } {
311- if got := ClassifyMirrorOutput(s, errors.New("exit"), false); got != MirrorClassAuthDenied {
312- t.Fatalf("%q: %s", s, got)
313- }
314- }
315-}
@@ -19,6 +19,7 @@ func TestMirrorURL(t *testing.T) {
19 }{19 }{
20 {"git@host:acme/repo", true}, {"host:repo", true}, {"ssh://git@host:2222/acme/repo", true}, {"ssh://[::1]/repo", true},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},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},
22 } {23 } {
23 t.Run(tt.s, func(t *testing.T) {24 t.Run(tt.s, func(t *testing.T) {
24 e := ValidateMirrorURL(tt.s)25 e := ValidateMirrorURL(tt.s)
@@ -79,7 +80,11 @@ func TestMirrorCronValidation(t *testing.T) {
79 spec string80 spec string
80 gap time.Duration81 gap time.Duration
81 valid bool82 valid bool
82- }{{"*/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}} {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+ } {
83 t.Run(tt.spec, func(t *testing.T) {88 t.Run(tt.spec, func(t *testing.T) {
84 c, e := ParseCron(tt.spec)89 c, e := ParseCron(tt.spec)
85 if e == nil && CronMinGap(c) != tt.gap {90 if e == nil && CronMinGap(c) != tt.gap {
@@ -125,6 +130,22 @@ func TestMirrorNextCron(t *testing.T) {
125 if _, ok := NextCron(CronSchedule{}, start); ok {130 if _, ok := NextCron(CronSchedule{}, start); ok {
126 t.Fatal("empty cron fired")131 t.Fatal("empty cron fired")
127 }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+ }
128 }149 }
129 func TestMirrorTransitions(t *testing.T) {150 func TestMirrorTransitions(t *testing.T) {
130 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)151 now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
@@ -192,15 +213,31 @@ func TestMirrorClassify(t *testing.T) {
192 for _, tt := range []struct {213 for _, tt := range []struct {
193 s string214 s string
194 c MirrorFailureClass215 c MirrorFailureClass
195- }{{"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}} {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},
196- if c := ClassifyMirrorOutput(tt.s, errors.New("exit"), false); c != tt.c {217+ {"Permission denied (publickey,password).", MirrorClassAuthDenied}, {"Permission denied (password,publickey,keyboard-interactive).", MirrorClassAuthDenied},
197- t.Fatal(tt, c)218+ {"ERROR: Permission to x/y.git denied to deploy key", MirrorClassAuthDenied}, {"remote: Permission to x/y.git denied to someone.", MirrorClassAuthDenied},
198- }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+ })
199 }225 }
200 if ClassifyMirrorOutput("", nil, false) != MirrorClassNone || ClassifyMirrorOutput("non-fast-forward", nil, true) != MirrorClassTransient {226 if ClassifyMirrorOutput("", nil, false) != MirrorClassNone || ClassifyMirrorOutput("non-fast-forward", nil, true) != MirrorClassTransient {
201 t.Fatal("classification")227 t.Fatal("classification")
202 }228 }
203 }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+}
204 func TestMirrorRefs(t *testing.T) {241 func TestMirrorRefs(t *testing.T) {
205 id, _ := ParseObjectID(strings.Repeat("a", 40))242 id, _ := ParseObjectID(strings.Repeat("a", 40))
206 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"}}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"}}
@@ -253,43 +290,6 @@ func TestMirrorKey(t *testing.T) {
253 }290 }
254 }291 }
255 292
256-func TestMirrorRecoveryFromFailing(t *testing.T) {
257- now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
258- for _, state := range []MirrorState{MirrorDisabled, MirrorOK, MirrorFailing, MirrorPaused} {
259- for _, noop := range []bool{false, true} {
260- tr := ApplyMirrorOutcome(MirrorStatus{State: state, ConsecutiveFailures: 2, Class: MirrorClassTransient, Detail: "old"}, MirrorOutcome{NoOp: noop}, now)
261- if tr.Recovered != (state == MirrorFailing || state == MirrorPaused) || tr.FailedEvent || tr.Status.ConsecutiveFailures != 0 || tr.Status.Class != "" || tr.Status.Reason != "" || !tr.Status.NextRetryAt.IsZero() {
262- t.Fatal(state, tr)
263- }
264- }
265- }
266-}
267-func TestMirrorNextCronFallBack(t *testing.T) {
268- loc, _ := time.LoadLocation("America/New_York")
269- c, _ := ParseCron("30 1 * * *")
270- // After the first 01:30, the repeated 01:30 must not fire again.
271- start := time.Date(2024, 11, 3, 5, 30, 0, 0, time.UTC)
272- got, ok := NextCron(CronSchedule{Cron: c, Loc: loc}, start)
273- want := time.Date(2024, 11, 4, 1, 30, 0, 0, loc)
274- if !ok || !got.Equal(want) {
275- t.Fatal(got, ok)
276- }
277-}
278-
279-func TestMirrorCronYearBoundary(t *testing.T) {
280- spec := "0,59 0,23 1,31 1,12 *"
281- c, err := ParseCron(spec)
282- if err != nil {
283- t.Fatal(err)
284- }
285- if got := CronMinGap(c); got != time.Minute {
286- t.Fatal(got)
287- }
288- if err := ValidateMirrorRepoConfig(MirrorRepoConfig{Cron: spec}); err == nil {
289- t.Fatal("accepted one-minute year-boundary gap")
290- }
291-}
292-
293 func TestMirrorResolutionOnlyParsesCron(t *testing.T) {293 func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
294 g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}294 g := MirrorGlobalConfig{Enabled: true, URLTemplate: "host:{repo}", Cron: "* * * * *"}
295 repo, _ := ParseRepoName("demo")295 repo, _ := ParseRepoName("demo")
@@ -300,16 +300,3 @@ func TestMirrorResolutionOnlyParsesCron(t *testing.T) {
300 t.Fatal("mutation must still reject short gap")300 t.Fatal("mutation must still reject short gap")
301 }301 }
302 }302 }
303-
304-func TestMirrorClassifyAuthVariants(t *testing.T) {
305- for _, s := range []string{
306- "Permission denied (publickey,password).",
307- "Permission denied (password,publickey,keyboard-interactive).",
308- "ERROR: Permission to x/y.git denied to deploy key",
309- "remote: Permission to x/y.git denied to someone.",
310- } {
311- if got := ClassifyMirrorOutput(s, errors.New("exit"), false); got != MirrorClassAuthDenied {
312- t.Fatalf("%q: %s", s, got)
313- }
314- }
315-}
M internal/ports/mirror.go +15 -5
@@ -16,10 +16,10 @@ type MirrorStore interface {
1616 GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) // domain.ErrNotFound when none
1717 PutRepoConfig(ctx context.Context, c domain.MirrorRepoConfig) error // ErrNotFound when the repository is gone (guardRepoKey)
1818 DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error // missing is not an error
19- ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) // by repo name
19+ ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) // by repo name; with ListStatus, what ListStatus of the service reads
2020 GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) // ErrNotFound when none
2121 PutStatus(ctx context.Context, s domain.MirrorStatus) error
22- ListStatus(ctx context.Context) ([]domain.MirrorStatus, error)
22+ ListStatus(ctx context.Context) ([]domain.MirrorStatus, error) // stored statuses only
2323 GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) // ErrNotFound when none
2424 PutSnapshot(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error
2525 }
@@ -72,15 +72,24 @@ type MirrorTrigger interface {
7272
7373 // MirrorWarner is what the post-receive hook and the web ask. *usecase.Mirrors implements it.
7474 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- Warning(ctx context.Context, repo domain.RepoName) string
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
7778 // Badge is the state and reason for a viewer: reason is "" unless p may administer the repository.
7879 Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string)
7980 }
8081 type MirrorSyncResult struct {
82+ Repo domain.RepoName
8183 Status domain.MirrorStatus
8284 Queued bool
8385 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
8493 }
8594
8695 // MirrorService is the inbound port of the API. Authorization: key show needs a non-anonymous
@@ -88,7 +97,7 @@ type MirrorSyncResult struct {
8897 // Sync need Allows(ScopeRepoAdmin, repo); GetStatus needs read access to the repository.
8998 type MirrorService interface {
9099 ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) // generates lazily
91- RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error)
100+ RotateKey(ctx context.Context, p domain.Principal) (MirrorKeyRotation, error)
92101 GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error)
93102 SetGlobalConfig(ctx context.Context, p domain.Principal, patch MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)
94103 SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error)
@@ -97,6 +106,7 @@ type MirrorService interface {
97106 GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (MirrorStatusView, error)
98107 ListStatus(ctx context.Context, p domain.Principal) ([]MirrorStatusView, error)
99108 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.
100110 SyncAll(ctx context.Context, p domain.Principal, force bool) ([]MirrorSyncResult, error)
101111 }
102112 type MirrorGlobalPatch struct {
@@ -16,10 +16,10 @@ type MirrorStore interface {
16 GetRepoConfig(ctx context.Context, repo domain.RepoName) (domain.MirrorRepoConfig, error) // domain.ErrNotFound when none16 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)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 error18 DeleteRepoConfig(ctx context.Context, repo domain.RepoName) error // missing is not an error
19- ListRepoConfigs(ctx context.Context) ([]domain.MirrorRepoConfig, error) // by repo name19+ 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 none20 GetStatus(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) // ErrNotFound when none
21 PutStatus(ctx context.Context, s domain.MirrorStatus) error21 PutStatus(ctx context.Context, s domain.MirrorStatus) error
22- ListStatus(ctx context.Context) ([]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 none23 GetSnapshot(ctx context.Context, repo domain.RepoName) (domain.MirrorSnapshot, error) // ErrNotFound when none
24 PutSnapshot(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error24 PutSnapshot(ctx context.Context, repo domain.RepoName, s domain.MirrorSnapshot) error
25 }25 }
@@ -72,15 +72,24 @@ type MirrorTrigger interface {
72 72
73 // MirrorWarner is what the post-receive hook and the web ask. *usecase.Mirrors implements it.73 // MirrorWarner is what the post-receive hook and the web ask. *usecase.Mirrors implements it.
74 type MirrorWarner interface {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>").75+ // Warning is non-empty when the mirror of repo is failing or paused ("mirror to github.com:x/y is paused: <reason>")
76- Warning(ctx context.Context, repo domain.RepoName) string76+ // 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
77 // Badge is the state and reason for a viewer: reason is "" unless p may administer the repository.78 // Badge is the state and reason for a viewer: reason is "" unless p may administer the repository.
78 Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string)79 Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string)
79 }80 }
80 type MirrorSyncResult struct {81 type MirrorSyncResult struct {
82+ Repo domain.RepoName
81 Status domain.MirrorStatus83 Status domain.MirrorStatus
82 Queued bool84 Queued bool
83 NoOp bool85 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
84 }93 }
85 94
86 // MirrorService is the inbound port of the API. Authorization: key show needs a non-anonymous95 // MirrorService is the inbound port of the API. Authorization: key show needs a non-anonymous
@@ -88,7 +97,7 @@ type MirrorSyncResult struct {
88 // Sync need Allows(ScopeRepoAdmin, repo); GetStatus needs read access to the repository.97 // Sync need Allows(ScopeRepoAdmin, repo); GetStatus needs read access to the repository.
89 type MirrorService interface {98 type MirrorService interface {
90 ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) // generates lazily99 ShowKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) // generates lazily
91- RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error)100+ RotateKey(ctx context.Context, p domain.Principal) (MirrorKeyRotation, error)
92 GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error)101 GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error)
93 SetGlobalConfig(ctx context.Context, p domain.Principal, patch MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)102 SetGlobalConfig(ctx context.Context, p domain.Principal, patch MirrorGlobalPatch) (domain.MirrorGlobalConfig, error)
94 SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error)103 SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error)
@@ -97,6 +106,7 @@ type MirrorService interface {
97 GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (MirrorStatusView, error)106 GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (MirrorStatusView, error)
98 ListStatus(ctx context.Context, p domain.Principal) ([]MirrorStatusView, error)107 ListStatus(ctx context.Context, p domain.Principal) ([]MirrorStatusView, error)
99 Sync(ctx context.Context, p domain.Principal, repo domain.RepoName, force bool) (MirrorSyncResult, 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.
100 SyncAll(ctx context.Context, p domain.Principal, force bool) ([]MirrorSyncResult, error)110 SyncAll(ctx context.Context, p domain.Principal, force bool) ([]MirrorSyncResult, error)
101 }111 }
102 type MirrorGlobalPatch struct {112 type MirrorGlobalPatch struct {
M internal/ports/mocks/MirrorService.go +8 -8
@@ -318,22 +318,22 @@ func (_c *MirrorService_RemoveRepoMirror_Call) RunAndReturn(run func(ctx context
318318 }
319319
320320 // RotateKey provides a mock function for the type MirrorService
321-func (_mock *MirrorService) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {
321+func (_mock *MirrorService) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
322322 ret := _mock.Called(ctx, p)
323323
324324 if len(ret) == 0 {
325325 panic("no return value specified for RotateKey")
326326 }
327327
328- var r0 domain.MirrorPublicKey
328+ var r0 ports.MirrorKeyRotation
329329 var r1 error
330- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (domain.MirrorPublicKey, error)); ok {
330+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.MirrorKeyRotation, error)); ok {
331331 return returnFunc(ctx, p)
332332 }
333- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) domain.MirrorPublicKey); ok {
333+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.MirrorKeyRotation); ok {
334334 r0 = returnFunc(ctx, p)
335335 } else {
336- r0 = ret.Get(0).(domain.MirrorPublicKey)
336+ r0 = ret.Get(0).(ports.MirrorKeyRotation)
337337 }
338338 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
339339 r1 = returnFunc(ctx, p)
@@ -373,12 +373,12 @@ func (_c *MirrorService_RotateKey_Call) Run(run func(ctx context.Context, p doma
373373 return _c
374374 }
375375
376-func (_c *MirrorService_RotateKey_Call) Return(mirrorPublicKey domain.MirrorPublicKey, err error) *MirrorService_RotateKey_Call {
377- _c.Call.Return(mirrorPublicKey, err)
376+func (_c *MirrorService_RotateKey_Call) Return(mirrorKeyRotation ports.MirrorKeyRotation, err error) *MirrorService_RotateKey_Call {
377+ _c.Call.Return(mirrorKeyRotation, err)
378378 return _c
379379 }
380380
381-func (_c *MirrorService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error)) *MirrorService_RotateKey_Call {
381+func (_c *MirrorService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error)) *MirrorService_RotateKey_Call {
382382 _c.Call.Return(run)
383383 return _c
384384 }
@@ -318,22 +318,22 @@ func (_c *MirrorService_RemoveRepoMirror_Call) RunAndReturn(run func(ctx context
318 }318 }
319 319
320 // RotateKey provides a mock function for the type MirrorService320 // RotateKey provides a mock function for the type MirrorService
321-func (_mock *MirrorService) RotateKey(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error) {321+func (_mock *MirrorService) RotateKey(ctx context.Context, p domain.Principal) (ports.MirrorKeyRotation, error) {
322 ret := _mock.Called(ctx, p)322 ret := _mock.Called(ctx, p)
323 323
324 if len(ret) == 0 {324 if len(ret) == 0 {
325 panic("no return value specified for RotateKey")325 panic("no return value specified for RotateKey")
326 }326 }
327 327
328- var r0 domain.MirrorPublicKey328+ var r0 ports.MirrorKeyRotation
329 var r1 error329 var r1 error
330- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (domain.MirrorPublicKey, error)); ok {330+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) (ports.MirrorKeyRotation, error)); ok {
331 return returnFunc(ctx, p)331 return returnFunc(ctx, p)
332 }332 }
333- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) domain.MirrorPublicKey); ok {333+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal) ports.MirrorKeyRotation); ok {
334 r0 = returnFunc(ctx, p)334 r0 = returnFunc(ctx, p)
335 } else {335 } else {
336- r0 = ret.Get(0).(domain.MirrorPublicKey)336+ r0 = ret.Get(0).(ports.MirrorKeyRotation)
337 }337 }
338 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {338 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal) error); ok {
339 r1 = returnFunc(ctx, p)339 r1 = returnFunc(ctx, p)
@@ -373,12 +373,12 @@ func (_c *MirrorService_RotateKey_Call) Run(run func(ctx context.Context, p doma
373 return _c373 return _c
374 }374 }
375 375
376-func (_c *MirrorService_RotateKey_Call) Return(mirrorPublicKey domain.MirrorPublicKey, err error) *MirrorService_RotateKey_Call {376+func (_c *MirrorService_RotateKey_Call) Return(mirrorKeyRotation ports.MirrorKeyRotation, err error) *MirrorService_RotateKey_Call {
377- _c.Call.Return(mirrorPublicKey, err)377+ _c.Call.Return(mirrorKeyRotation, err)
378 return _c378 return _c
379 }379 }
380 380
381-func (_c *MirrorService_RotateKey_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal) (domain.MirrorPublicKey, error)) *MirrorService_RotateKey_Call {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)382 _c.Call.Return(run)
383 return _c383 return _c
384 }384 }
M internal/ports/mocks/MirrorWarner.go +16 -10
@@ -120,16 +120,16 @@ func (_c *MirrorWarner_Badge_Call) RunAndReturn(run func(ctx context.Context, p
120120 }
121121
122122 // Warning provides a mock function for the type MirrorWarner
123-func (_mock *MirrorWarner) Warning(ctx context.Context, repo domain.RepoName) string {
124- ret := _mock.Called(ctx, repo)
123+func (_mock *MirrorWarner) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
124+ ret := _mock.Called(ctx, p, repo)
125125
126126 if len(ret) == 0 {
127127 panic("no return value specified for Warning")
128128 }
129129
130130 var r0 string
131- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) string); ok {
132- r0 = returnFunc(ctx, repo)
131+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) string); ok {
132+ r0 = returnFunc(ctx, p, repo)
133133 } else {
134134 r0 = ret.Get(0).(string)
135135 }
@@ -143,24 +143,30 @@ type MirrorWarner_Warning_Call struct {
143143
144144 // Warning is a helper method to define mock.On call
145145 // - ctx context.Context
146+// - p domain.Principal
146147 // - repo domain.RepoName
147-func (_e *MirrorWarner_Expecter) Warning(ctx any, repo any) *MirrorWarner_Warning_Call {
148- return &MirrorWarner_Warning_Call{Call: _e.mock.On("Warning", ctx, repo)}
148+func (_e *MirrorWarner_Expecter) Warning(ctx any, p any, repo any) *MirrorWarner_Warning_Call {
149+ return &MirrorWarner_Warning_Call{Call: _e.mock.On("Warning", ctx, p, repo)}
149150 }
150151
151-func (_c *MirrorWarner_Warning_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorWarner_Warning_Call {
152+func (_c *MirrorWarner_Warning_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorWarner_Warning_Call {
152153 _c.Call.Run(func(args mock.Arguments) {
153154 var arg0 context.Context
154155 if args[0] != nil {
155156 arg0 = args[0].(context.Context)
156157 }
157- var arg1 domain.RepoName
158+ var arg1 domain.Principal
158159 if args[1] != nil {
159- arg1 = args[1].(domain.RepoName)
160+ arg1 = args[1].(domain.Principal)
161+ }
162+ var arg2 domain.RepoName
163+ if args[2] != nil {
164+ arg2 = args[2].(domain.RepoName)
160165 }
161166 run(
162167 arg0,
163168 arg1,
169+ arg2,
164170 )
165171 })
166172 return _c
@@ -171,7 +177,7 @@ func (_c *MirrorWarner_Warning_Call) Return(s string) *MirrorWarner_Warning_Call
171177 return _c
172178 }
173179
174-func (_c *MirrorWarner_Warning_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) string) *MirrorWarner_Warning_Call {
180+func (_c *MirrorWarner_Warning_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) string) *MirrorWarner_Warning_Call {
175181 _c.Call.Return(run)
176182 return _c
177183 }
@@ -120,16 +120,16 @@ func (_c *MirrorWarner_Badge_Call) RunAndReturn(run func(ctx context.Context, p
120 }120 }
121 121
122 // Warning provides a mock function for the type MirrorWarner122 // Warning provides a mock function for the type MirrorWarner
123-func (_mock *MirrorWarner) Warning(ctx context.Context, repo domain.RepoName) string {123+func (_mock *MirrorWarner) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
124- ret := _mock.Called(ctx, repo)124+ ret := _mock.Called(ctx, p, repo)
125 125
126 if len(ret) == 0 {126 if len(ret) == 0 {
127 panic("no return value specified for Warning")127 panic("no return value specified for Warning")
128 }128 }
129 129
130 var r0 string130 var r0 string
131- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) string); ok {131+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) string); ok {
132- r0 = returnFunc(ctx, repo)132+ r0 = returnFunc(ctx, p, repo)
133 } else {133 } else {
134 r0 = ret.Get(0).(string)134 r0 = ret.Get(0).(string)
135 }135 }
@@ -143,24 +143,30 @@ type MirrorWarner_Warning_Call struct {
143 143
144 // Warning is a helper method to define mock.On call144 // Warning is a helper method to define mock.On call
145 // - ctx context.Context145 // - ctx context.Context
146+// - p domain.Principal
146 // - repo domain.RepoName147 // - repo domain.RepoName
147-func (_e *MirrorWarner_Expecter) Warning(ctx any, repo any) *MirrorWarner_Warning_Call {148+func (_e *MirrorWarner_Expecter) Warning(ctx any, p any, repo any) *MirrorWarner_Warning_Call {
148- return &MirrorWarner_Warning_Call{Call: _e.mock.On("Warning", ctx, repo)}149+ return &MirrorWarner_Warning_Call{Call: _e.mock.On("Warning", ctx, p, repo)}
149 }150 }
150 151
151-func (_c *MirrorWarner_Warning_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MirrorWarner_Warning_Call {152+func (_c *MirrorWarner_Warning_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName)) *MirrorWarner_Warning_Call {
152 _c.Call.Run(func(args mock.Arguments) {153 _c.Call.Run(func(args mock.Arguments) {
153 var arg0 context.Context154 var arg0 context.Context
154 if args[0] != nil {155 if args[0] != nil {
155 arg0 = args[0].(context.Context)156 arg0 = args[0].(context.Context)
156 }157 }
157- var arg1 domain.RepoName158+ var arg1 domain.Principal
158 if args[1] != nil {159 if args[1] != nil {
159- arg1 = args[1].(domain.RepoName)160+ arg1 = args[1].(domain.Principal)
161+ }
162+ var arg2 domain.RepoName
163+ if args[2] != nil {
164+ arg2 = args[2].(domain.RepoName)
160 }165 }
161 run(166 run(
162 arg0,167 arg0,
163 arg1,168 arg1,
169+ arg2,
164 )170 )
165 })171 })
166 return _c172 return _c
@@ -171,7 +177,7 @@ func (_c *MirrorWarner_Warning_Call) Return(s string) *MirrorWarner_Warning_Call
171 return _c177 return _c
172 }178 }
173 179
174-func (_c *MirrorWarner_Warning_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) string) *MirrorWarner_Warning_Call {180+func (_c *MirrorWarner_Warning_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName) string) *MirrorWarner_Warning_Call {
175 _c.Call.Return(run)181 _c.Call.Return(run)
176 return _c182 return _c
177 }183 }

Keyboard shortcuts

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

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

Branches 3

Tags 43

↑↓ moveenter openesc close