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}