mirrors_config.go RawHistoryFolder

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}

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 4

Tags 45

↑↓ moveenter openesc close