v3.4.0 593c1c3

targets.go RawHistoryFolder

1package backup
2
3import (
4	"context"
5	"fmt"
6	"sort"
7	"strings"
8
9	"github.com/bnema/gordon/internal/boundaries/out"
10	"github.com/bnema/gordon/internal/domain"
11)
12
13// This file resolves backup targets from the declarative app state. There
14// is no image, port, container-label, or attachment heuristic anywhere:
15// a target exists because the app's ACTIVE record declares it, and its
16// identity is always (app, service, resource).
17
18// AppStateReader is the ACTIVE/intent subset target resolution needs.
19// It is narrower than out.AppState so backup paths never gain write
20// access.
21type AppStateReader = out.AppStateReader
22
23// declaredTargets walks the ACTIVE record of one app and returns its
24// declared database and volume targets. STOPPED-intent apps have no
25// target: their workloads are intentionally down.
26func declaredTargets(ctx context.Context, state AppStateReader, app string) (databaseTargets []domain.DatabaseTarget, volumeTargets []domain.VolumeBackupTarget, err error) {
27	intent, err := state.LoadIntent(ctx, app)
28	if err != nil {
29		return nil, nil, fmt.Errorf("backup: load intent for %q: %w", app, err)
30	}
31	if intent.Stopped {
32		return nil, nil, nil
33	}
34	active, ok, err := state.LoadActive(ctx, app)
35	if err != nil {
36		return nil, nil, fmt.Errorf("backup: load active for %q: %w", app, err)
37	}
38	if !ok {
39		return nil, nil, nil
40	}
41	names := make([]string, 0, len(active.Services))
42	for name := range active.Services {
43		names = append(names, name)
44	}
45	sort.Strings(names)
46	for _, name := range names {
47		eff := active.Services[name]
48		databaseTargets = append(databaseTargets, databaseTargetsForService(app, name, eff)...)
49		volumeTargets = append(volumeTargets, volumeTargetsForService(app, name, eff)...)
50	}
51	return databaseTargets, volumeTargets, nil
52}
53
54// databaseTargetsForService returns the declared PostgreSQL databases of
55// one service that its backup declaration references. Only PostgreSQL is
56// supported, and the spec validation already rejects anything else.
57func databaseTargetsForService(app, service string, eff domain.AppEffectiveService) []domain.DatabaseTarget {
58	referenced := make(map[string]struct{}, len(eff.Spec.Backup.Postgres))
59	for _, ref := range eff.Spec.Backup.Postgres {
60		referenced[ref] = struct{}{}
61	}
62	var targets []domain.DatabaseTarget
63	for _, db := range eff.Spec.Databases {
64		if _, ok := referenced[db.Name]; !ok {
65			continue
66		}
67		targets = append(targets, domain.DatabaseTarget{
68			App:         app,
69			Service:     service,
70			Database:    db.Name,
71			Schedule:    domain.BackupSchedule(db.Schedule),
72			ContainerID: eff.Container,
73		})
74	}
75	return targets
76}
77
78// volumeTargetsForService returns the declared volumes of one service that
79// its backup declaration references, with the runtime volume name and
80// mount path the archive export needs.
81func volumeTargetsForService(app, service string, eff domain.AppEffectiveService) []domain.VolumeBackupTarget {
82	referenced := make(map[string]struct{}, len(eff.Spec.Backup.Volume))
83	for _, ref := range eff.Spec.Backup.Volume {
84		referenced[ref] = struct{}{}
85	}
86	var targets []domain.VolumeBackupTarget
87	for _, volume := range eff.Spec.Volumes {
88		if _, ok := referenced[volume.Name]; !ok {
89			continue
90		}
91		targets = append(targets, domain.VolumeBackupTarget{
92			App:               app,
93			Service:           service,
94			VolumeName:        volume.Name,
95			RuntimeVolumeName: domain.RuntimeVolumeName(app, service, volume.Name),
96			MountPath:         volume.Path,
97		})
98	}
99	return targets
100}
101
102// selectTarget applies the explicit-selector rules to one candidate list:
103// an explicit service and resource must match exactly, while an omitted
104// selector succeeds only when exactly one candidate remains. Nothing is
105// ever chosen by guessing, and an ambiguity never lists anything but the
106// safe target identity (service and resource names).
107func selectTarget[T any](candidates []T, kind, service, resource string, key func(T) (string, string)) (T, error) {
108	var zero T
109	matched := candidates
110	if service != "" {
111		matched = filterTargets(matched, func(candidate T) bool {
112			candidateService, _ := key(candidate)
113			return candidateService == service
114		})
115	}
116	if resource != "" {
117		matched = filterTargets(matched, func(candidate T) bool {
118			_, candidateResource := key(candidate)
119			return strings.EqualFold(candidateResource, resource)
120		})
121	}
122	switch len(matched) {
123	case 1:
124		return matched[0], nil
125	case 0:
126		return zero, fmt.Errorf("backup: %s %s not found", kind, selectorLabel(service, resource))
127	default:
128		names := make([]string, 0, len(matched))
129		for _, candidate := range matched {
130			candidateService, candidateResource := key(candidate)
131			names = append(names, candidateService+"/"+candidateResource)
132		}
133		sort.Strings(names)
134		return zero, fmt.Errorf(
135			"backup: %s selector %s is ambiguous: candidates %s",
136			kind, selectorLabel(service, resource), strings.Join(names, ", "),
137		)
138	}
139}
140
141func filterTargets[T any](candidates []T, keep func(T) bool) []T {
142	matched := make([]T, 0, len(candidates))
143	for _, candidate := range candidates {
144		if keep(candidate) {
145			matched = append(matched, candidate)
146		}
147	}
148	return matched
149}
150
151// selectorLabel renders the selectors a caller supplied, for errors.
152func selectorLabel(service, resource string) string {
153	switch {
154	case service != "" && resource != "":
155		return service + "/" + resource
156	case resource != "":
157		return resource
158	case service != "":
159		return "of service " + service
160	default:
161		return "without selectors"
162	}
163}
164
165func appNames(ctx context.Context, state AppStateReader) ([]string, error) {
166	apps, err := state.ListApps(ctx)
167	if err != nil {
168		return nil, fmt.Errorf("backup: list apps: %w", err)
169	}
170	sort.Strings(apps)
171	return apps, nil
172}

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 7

Tags 106

↑↓ moveenter openesc close