1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "errors"
8 "time"
9
10 "bnema.dev/ermite/internal/domain"
11 "bnema.dev/ermite/internal/ports"
12)
13
14func mirrorOwner(p domain.Principal) error {
15 if p.IsAnonymous() {
16 return domain.ErrUnauthenticated
17 }
18 if !p.HasOwnerRights() {
19 return domain.ErrForbidden
20 }
21 return nil
22}
23func (m *Mirrors) admin(ctx context.Context, p domain.Principal, repo domain.RepoName) error {
24 if p.IsAnonymous() {
25 return domain.ErrUnauthenticated
26 }
27 r, err := m.cfg.Meta.Get(ctx, repo)
28 if err != nil {
29 return err
30 }
31 if !p.Allows(domain.ScopeRepoAdmin, repo) {
32 if !canRead(p, r) {
33 return domain.ErrNotFound
34 }
35 return domain.ErrForbidden
36 }
37 return nil
38}
39func (m *Mirrors) target(ctx context.Context, repo domain.RepoName) (domain.MirrorTarget, error) {
40 g, err := m.cfg.Store.GetGlobal(ctx)
41 if err != nil {
42 return domain.MirrorTarget{}, err
43 }
44 r, err := m.cfg.Store.GetRepoConfig(ctx, repo)
45 if errors.Is(err, domain.ErrNotFound) {
46 return m.resolve(g, nil, repo)
47 }
48 if err != nil {
49 return domain.MirrorTarget{}, err
50 }
51 return m.resolve(g, &r, repo)
52}
53func (m *Mirrors) status(ctx context.Context, repo domain.RepoName) (domain.MirrorStatus, error) {
54 s, err := m.cfg.Store.GetStatus(ctx, repo)
55 if errors.Is(err, domain.ErrNotFound) {
56 return domain.MirrorStatus{Repo: repo, State: domain.MirrorDisabled}, nil
57 }
58 return s, err
59}
60
61// GetGlobalConfig returns server-wide policy to an owner.
62func (m *Mirrors) GetGlobalConfig(ctx context.Context, p domain.Principal) (domain.MirrorGlobalConfig, error) {
63 if err := mirrorOwner(p); err != nil {
64 return domain.MirrorGlobalConfig{}, err
65 }
66 return m.cfg.Store.GetGlobal(ctx)
67}
68
69// SetGlobalConfig patches server-wide policy and resets affected mirror incidents.
70func (m *Mirrors) SetGlobalConfig(ctx context.Context, p domain.Principal, patch ports.MirrorGlobalPatch) (domain.MirrorGlobalConfig, error) {
71 if err := mirrorOwner(p); err != nil {
72 return domain.MirrorGlobalConfig{}, err
73 }
74 m.configMu.Lock()
75 defer m.configMu.Unlock()
76 old, err := m.cfg.Store.GetGlobal(ctx)
77 if err != nil {
78 return old, err
79 }
80 g := old
81 if patch.Enabled != nil {
82 g.Enabled = *patch.Enabled
83 }
84 if patch.URLTemplate != nil {
85 g.URLTemplate = *patch.URLTemplate
86 }
87 if patch.Cron != nil {
88 g.Cron = *patch.Cron
89 }
90 if patch.AllBranches != nil {
91 g.AllBranches = *patch.AllBranches
92 }
93 if err := m.validateGlobal(g); err != nil {
94 return old, err
95 }
96 repos, err := m.cfg.Meta.List(ctx)
97 if err != nil {
98 return old, err
99 }
100 // Resolve every effective target before and after, and persist nothing
101 // unless all of them stay valid.
102 var changed []domain.RepoName
103 for _, r := range repos {
104 c, e := m.cfg.Store.GetRepoConfig(ctx, r.Name)
105 var cp *domain.MirrorRepoConfig
106 if e == nil {
107 cp = &c
108 } else if !errors.Is(e, domain.ErrNotFound) {
109 return old, e
110 }
111 before, _ := m.resolve(old, cp, r.Name)
112 after, e := m.resolve(g, cp, r.Name)
113 if e != nil {
114 return old, invalidCron(e)
115 }
116 if !sameMirrorTarget(before, after) {
117 changed = append(changed, r.Name)
118 }
119 }
120 g.UpdatedAt = m.cfg.Clock.Now().UTC()
121 if err := m.cfg.Store.PutGlobal(ctx, g); err != nil {
122 return old, err
123 }
124 // One failing reset must not leave the others stale: the policy is saved.
125 var errs []error
126 for _, name := range changed {
127 if err := m.reset(ctx, name); err != nil {
128 errs = append(errs, err)
129 }
130 }
131 return g, errors.Join(errs...)
132}
133
134func invalidCron(e error) error {
135 var inv *domain.InvalidFieldsError
136 if errors.As(e, &inv) {
137 return e
138 }
139 return &domain.InvalidFieldsError{Fields: []domain.FieldViolation{{Field: "cron", Reason: e.Error()}}}
140}
141
142func sameMirrorTarget(a, b domain.MirrorTarget) bool {
143 return a.Enabled == b.Enabled && a.URL == b.URL && a.CronSpec == b.CronSpec && a.AllBranches == b.AllBranches && a.Source == b.Source
144}
145
146// reset lifts the incident of a repository whose effective target changed and marks it dirty.
147func (m *Mirrors) reset(ctx context.Context, repo domain.RepoName) error {
148 t, err := m.target(ctx, repo)
149 if err != nil {
150 return err
151 }
152 s, err := m.status(ctx, repo)
153 if err != nil {
154 return err
155 }
156 now := m.cfg.Clock.Now().UTC()
157 if t.Enabled {
158 s = domain.MirrorReset(s, now)
159 } else {
160 s = domain.MirrorDisable(s, now)
161 }
162 m.mu.Lock()
163 g := m.gens[repo]
164 g.dirty++
165 m.gens[repo] = g
166 m.mu.Unlock()
167 return m.cfg.Store.PutStatus(ctx, s)
168}
169
170// resetIfChanged resets repo when its effective target is no longer before. configMu is held.
171func (m *Mirrors) resetIfChanged(ctx context.Context, repo domain.RepoName, before domain.MirrorTarget, beforeErr error) (domain.MirrorTarget, error) {
172 after, err := m.target(ctx, repo)
173 if err != nil {
174 return after, err
175 }
176 if beforeErr == nil && sameMirrorTarget(before, after) {
177 return after, nil
178 }
179 return after, m.reset(ctx, repo)
180}
181
182// SetRepoMirror sets a repository override.
183func (m *Mirrors) SetRepoMirror(ctx context.Context, p domain.Principal, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
184 if err := m.admin(ctx, p, c.Repo); err != nil {
185 return domain.MirrorTarget{}, err
186 }
187 if err := m.validateRepo(c); err != nil {
188 return domain.MirrorTarget{}, err
189 }
190 m.configMu.Lock()
191 defer m.configMu.Unlock()
192 return m.setRepoLocked(ctx, c)
193}
194
195// setRepoLocked validates the prospective target, then persists. configMu must be held.
196func (m *Mirrors) setRepoLocked(ctx context.Context, c domain.MirrorRepoConfig) (domain.MirrorTarget, error) {
197 g, err := m.cfg.Store.GetGlobal(ctx)
198 if err != nil {
199 return domain.MirrorTarget{}, err
200 }
201 if _, err := m.resolve(g, &c, c.Repo); err != nil {
202 return domain.MirrorTarget{}, invalidCron(err)
203 }
204 before, beforeErr := m.target(ctx, c.Repo)
205 c.UpdatedAt = m.cfg.Clock.Now().UTC()
206 if err := m.cfg.Store.PutRepoConfig(ctx, c); err != nil {
207 return domain.MirrorTarget{}, err
208 }
209 return m.resetIfChanged(ctx, c.Repo, before, beforeErr)
210}
211
212// RemoveRepoMirror restores inherited policy.
213func (m *Mirrors) RemoveRepoMirror(ctx context.Context, p domain.Principal, repo domain.RepoName) error {
214 if err := m.admin(ctx, p, repo); err != nil {
215 return err
216 }
217 m.configMu.Lock()
218 defer m.configMu.Unlock()
219 before, beforeErr := m.target(ctx, repo)
220 if err := m.cfg.Store.DeleteRepoConfig(ctx, repo); err != nil {
221 return err
222 }
223 _, err := m.resetIfChanged(ctx, repo, before, beforeErr)
224 return err
225}
226
227// SetRepoExcluded updates exclusion without discarding an override.
228func (m *Mirrors) SetRepoExcluded(ctx context.Context, p domain.Principal, repo domain.RepoName, excluded bool) (domain.MirrorTarget, error) {
229 if err := m.admin(ctx, p, repo); err != nil {
230 return domain.MirrorTarget{}, err
231 }
232 m.configMu.Lock()
233 defer m.configMu.Unlock()
234 c, err := m.cfg.Store.GetRepoConfig(ctx, repo)
235 if err != nil && !errors.Is(err, domain.ErrNotFound) {
236 return domain.MirrorTarget{}, err
237 }
238 c.Repo, c.Excluded = repo, excluded
239 return m.setRepoLocked(ctx, c)
240}
241
242// GetStatus returns a read-authorized status, redacting administrative diagnostics.
243func (m *Mirrors) GetStatus(ctx context.Context, p domain.Principal, repo domain.RepoName) (ports.MirrorStatusView, error) {
244 return m.statusView(ctx, p, repo, true)
245}
246
247// statusView builds the view of one repository; withNext computes the next scheduled run.
248func (m *Mirrors) statusView(ctx context.Context, p domain.Principal, repo domain.RepoName, withNext bool) (ports.MirrorStatusView, error) {
249 r, err := m.cfg.Meta.Get(ctx, repo)
250 if err != nil {
251 return ports.MirrorStatusView{}, err
252 }
253 if !canRead(p, r) {
254 return ports.MirrorStatusView{}, domain.ErrNotFound
255 }
256 t, err := m.target(ctx, repo)
257 if err != nil {
258 return ports.MirrorStatusView{}, err
259 }
260 s, err := m.status(ctx, repo)
261 if err != nil {
262 return ports.MirrorStatusView{}, err
263 }
264 return m.view(p, t, s, withNext), nil
265}
266
267func (m *Mirrors) view(p domain.Principal, t domain.MirrorTarget, s domain.MirrorStatus, withNext bool) ports.MirrorStatusView {
268 var next time.Time
269 if withNext && t.Enabled && s.State != domain.MirrorPaused {
270 if s.State == domain.MirrorFailing {
271 next = s.NextRetryAt
272 } else {
273 next, _ = domain.NextCron(domain.CronSchedule{Cron: t.Cron, Loc: time.UTC}, m.cfg.Clock.Now())
274 }
275 }
276 if !p.Allows(domain.ScopeRepoAdmin, t.Repo) {
277 s.Detail, s.Reason = "", ""
278 t.URL = ""
279 }
280 return ports.MirrorStatusView{Target: t, Status: s, NextRunAt: next}
281}
282
283// ListStatus returns all statuses to an owner, from three reads instead of three per repository.
284func (m *Mirrors) ListStatus(ctx context.Context, p domain.Principal) ([]ports.MirrorStatusView, error) {
285 if err := mirrorOwner(p); err != nil {
286 return nil, err
287 }
288 repos, err := m.cfg.Meta.List(ctx)
289 if err != nil {
290 return nil, err
291 }
292 g, err := m.cfg.Store.GetGlobal(ctx)
293 if err != nil {
294 return nil, err
295 }
296 configs, err := m.cfg.Store.ListRepoConfigs(ctx)
297 if err != nil {
298 return nil, err
299 }
300 byRepo := make(map[domain.RepoName]domain.MirrorRepoConfig, len(configs))
301 for _, c := range configs {
302 byRepo[c.Repo] = c
303 }
304 statuses, err := m.cfg.Store.ListStatus(ctx)
305 if err != nil {
306 return nil, err
307 }
308 stored := make(map[domain.RepoName]domain.MirrorStatus, len(statuses))
309 for _, s := range statuses {
310 stored[s.Repo] = s
311 }
312 out := make([]ports.MirrorStatusView, 0, len(repos))
313 for _, r := range repos {
314 var cp *domain.MirrorRepoConfig
315 if c, ok := byRepo[r.Name]; ok {
316 cp = &c
317 }
318 t, err := m.resolve(g, cp, r.Name)
319 if err != nil {
320 return nil, err
321 }
322 s, ok := stored[r.Name]
323 if !ok {
324 s = domain.MirrorStatus{Repo: r.Name, State: domain.MirrorDisabled}
325 }
326 out = append(out, m.view(p, t, s, true))
327 }
328 return out, nil
329}
330
331// Warning provides the post-receive diagnostic of a failing or paused mirror to the admins of
332// the repository only: it names the remote and the reason.
333func (m *Mirrors) Warning(ctx context.Context, p domain.Principal, repo domain.RepoName) string {
334 if !p.Allows(domain.ScopeRepoAdmin, repo) {
335 return ""
336 }
337 s, err := m.status(ctx, repo)
338 if err != nil || (s.State != domain.MirrorFailing && s.State != domain.MirrorPaused) {
339 return ""
340 }
341 t, err := m.target(ctx, repo)
342 if err != nil {
343 return ""
344 }
345 return "ermite: mirror to " + domain.RedactMirrorURL(t.URL) + " is " + string(s.State) + ": " + s.Reason
346}
347
348// Badge returns a viewer-safe state and optional administrative reason.
349func (m *Mirrors) Badge(ctx context.Context, p domain.Principal, repo domain.RepoName) (domain.MirrorState, string) {
350 v, err := m.statusView(ctx, p, repo, false)
351 if err != nil {
352 return domain.MirrorDisabled, ""
353 }
354 return v.Status.State, v.Status.Reason
355}
356
357func (m *Mirrors) validateGlobal(g domain.MirrorGlobalConfig) error {
358 return domain.ValidateMirrorGlobalConfigWith(g, m.cfg.ValidateURL)
359}
360func (m *Mirrors) validateRepo(c domain.MirrorRepoConfig) error {
361 return domain.ValidateMirrorRepoConfigWith(c, m.cfg.ValidateURL)
362}
363func (m *Mirrors) resolve(g domain.MirrorGlobalConfig, c *domain.MirrorRepoConfig, repo domain.RepoName) (domain.MirrorTarget, error) {
364 return domain.ResolveMirrorWith(g, c, repo, m.cfg.ValidateURL)
365}