1package backup
2
3import (
4 "context"
5 "io"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20// appSpecWithDatabase builds one service that declares a database and
21// references it from its backup declaration.
22func appSpecWithDatabase(service, database, schedule string) domain.AppService {
23 return domain.AppService{
24 Name: service,
25 Image: "postgres:17",
26 Databases: []domain.AppDatabase{{Name: database, Type: domain.AppDBPostgres, Schedule: schedule}},
27 Backup: domain.AppBackup{Postgres: []string{database}},
28 }
29}
30
31// activeWith builds an ACTIVE record holding one effective service.
32func activeWith(app string, services map[string]domain.AppService, container string) domain.AppActive {
33 effective := make(map[string]domain.AppEffectiveService, len(services))
34 for name, spec := range services {
35 effective[name] = domain.AppEffectiveService{
36 EffectiveRevision: "rev-1",
37 Container: container,
38 Spec: spec,
39 }
40 }
41 return domain.AppActive{App: app, Converged: true, ConvergedRevision: "rev-1", Services: effective}
42}
43
44func backupTestService(t *testing.T, state *outmocks.MockAppStateReader) (*outmocks.MockContainerRuntime, *outmocks.MockBackupStorage, *Service) {
45 t.Helper()
46 runtime := outmocks.NewMockContainerRuntime(t)
47 storage := outmocks.NewMockBackupStorage(t)
48 svc := &Service{runtime: runtime, storage: storage, log: zerowrap.Default(), state: state}
49 return runtime, storage, svc
50}
51
52// TestService_TargetsResolveFromDeclarations proves a target exists only
53// because the ACTIVE record declares it: no image, port, or label is
54// inspected.
55func TestService_TargetsResolveFromDeclarations(t *testing.T) {
56 ctx := context.Background()
57 state := outmocks.NewMockAppStateReader(t)
58 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
59 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
60 "api": appSpecWithDatabase("api", "orders", "daily"),
61 "web": appSpecWithDatabase("web", "users", "hourly"),
62 }, "ctr-api"), true, nil).Once()
63
64 _, _, svc := backupTestService(t, state)
65 targets, err := svc.Targets(ctx, "shop")
66 require.NoError(t, err)
67 require.Len(t, targets, 2)
68 assert.Equal(t, domain.DatabaseTarget{
69 App: "shop", Service: "api", Database: "orders",
70 Schedule: domain.ScheduleDaily, ContainerID: "ctr-api",
71 }, targets[0])
72 assert.Equal(t, "users", targets[1].Database)
73 assert.Equal(t, domain.ScheduleHourly, targets[1].Schedule)
74}
75
76// TestService_TargetsIgnoreUnreferencedDatabases proves a declared
77// database the backup declaration does not reference is not a target.
78func TestService_TargetsIgnoreUnreferencedDatabases(t *testing.T) {
79 ctx := context.Background()
80 state := outmocks.NewMockAppStateReader(t)
81 spec := appSpecWithDatabase("api", "orders", "daily")
82 spec.Databases = append(spec.Databases, domain.AppDatabase{Name: "metrics", Type: domain.AppDBPostgres, Schedule: "daily"})
83 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
84 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{"api": spec}, "ctr-api"), true, nil).Once()
85
86 _, _, svc := backupTestService(t, state)
87 targets, err := svc.Targets(ctx, "shop")
88 require.NoError(t, err)
89 require.Len(t, targets, 1)
90 assert.Equal(t, "orders", targets[0].Database)
91}
92
93// TestService_TargetsSkipStoppedApps proves a stopped app has no target:
94// its workloads are intentionally down.
95func TestService_TargetsSkipStoppedApps(t *testing.T) {
96 ctx := context.Background()
97 state := outmocks.NewMockAppStateReader(t)
98 state.EXPECT().LoadIntent(mock.Anything, "shop").
99 Return(domain.AppStopIntent{App: "shop", Stopped: true}, nil).Once()
100
101 _, _, svc := backupTestService(t, state)
102 targets, err := svc.Targets(ctx, "shop")
103 require.NoError(t, err)
104 assert.Empty(t, targets)
105}
106
107// TestService_RunBackup_StoresUnderAppIdentity proves a run dumps from the
108// declared service container and stores the artifact under the app name.
109func TestService_RunBackup_StoresUnderAppIdentity(t *testing.T) {
110 ctx := context.Background()
111 state := outmocks.NewMockAppStateReader(t)
112 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
113 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
114 "api": appSpecWithDatabase("api", "orders", "daily"),
115 }, "ctr-api"), true, nil).Once()
116
117 runtime, storage, svc := backupTestService(t, state)
118 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
119 return len(cmd) == 3 && strings.Contains(cmd[2], "'orders'") && !strings.Contains(cmd[2], "POSTGRES_DB")
120 })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
121 runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
122 Return(io.NopCloser(strings.NewReader("dump-bytes")), nil).Once()
123 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
124 return len(cmd) == 3 && strings.HasPrefix(cmd[2], "rm -f ")
125 })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
126 storage.EXPECT().Store(mock.Anything, "shop", "api", "orders", domain.BackupSchedule(""), mock.Anything, mock.Anything).
127 RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
128 if _, err := io.Copy(io.Discard, data); err != nil {
129 return "", err
130 }
131 return "/backups/shop/orders/manual/file.bak", nil
132 }).Once()
133
134 result, err := svc.RunBackup(ctx, "shop", "api", "orders")
135 require.NoError(t, err)
136 assert.Equal(t, "shop", result.Job.App)
137 assert.Equal(t, "api", result.Job.Service)
138 assert.Equal(t, "orders", result.Job.DBName)
139 assert.Equal(t, int64(len("dump-bytes")), result.Job.SizeBytes)
140 assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
141}
142
143// TestService_RunBackup_SelectorRules proves the explicit-selector rules:
144// a missing selector with several candidates lists them, and an unknown
145// target is not found.
146func TestService_RunBackup_SelectorRules(t *testing.T) {
147 ctx := context.Background()
148 state := outmocks.NewMockAppStateReader(t)
149 active := activeWith("shop", map[string]domain.AppService{
150 "api": appSpecWithDatabase("api", "orders", "daily"),
151 "web": appSpecWithDatabase("web", "users", "daily"),
152 }, "ctr-x")
153 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil)
154 state.EXPECT().LoadActive(mock.Anything, "shop").Return(active, true, nil)
155
156 _, _, svc := backupTestService(t, state)
157
158 _, err := svc.RunBackup(ctx, "shop", "", "")
159 require.Error(t, err)
160 assert.Contains(t, err.Error(), "ambiguous")
161 assert.Contains(t, err.Error(), "api/orders")
162 assert.Contains(t, err.Error(), "web/users")
163
164 _, err = svc.RunBackup(ctx, "shop", "api", "missing")
165 require.Error(t, err)
166 assert.Contains(t, err.Error(), "not found")
167
168 _, err = svc.RunBackup(ctx, "shop", "missing", "orders")
169 require.Error(t, err)
170 assert.Contains(t, err.Error(), "not found")
171}
172
173// TestService_RunForSchedule_RunsOnlyTheDeclaredSchedule proves the
174// scheduler filters by each database's own declaration.
175func TestService_RunForSchedule_RunsOnlyTheDeclaredSchedule(t *testing.T) {
176 ctx := context.Background()
177 state := outmocks.NewMockAppStateReader(t)
178 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
179 spec := appSpecWithDatabase("api", "orders", "daily")
180 spec.Databases = append(spec.Databases, domain.AppDatabase{Name: "users", Type: domain.AppDBPostgres, Schedule: "hourly"})
181 spec.Backup.Postgres = append(spec.Backup.Postgres, "users")
182 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
183 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{"api": spec}, "ctr-api"), true, nil).Once()
184
185 runtime, storage, svc := backupTestService(t, state)
186 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
187 return strings.Contains(cmd[2], "'users'")
188 })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
189 runtime.EXPECT().CopyFromContainer(mock.Anything, "ctr-api", mock.Anything).
190 Return(io.NopCloser(strings.NewReader("x")), nil).Once()
191 runtime.EXPECT().ExecInContainer(mock.Anything, "ctr-api", mock.MatchedBy(func(cmd []string) bool {
192 return strings.HasPrefix(cmd[2], "rm -f ")
193 })).Return(&out.ExecResult{ExitCode: 0}, nil).Once()
194 storage.EXPECT().Store(mock.Anything, "shop", "api", "users", domain.ScheduleHourly, mock.Anything, mock.Anything).
195 RunAndReturn(func(_ context.Context, _, _, _ string, _ domain.BackupSchedule, _ time.Time, data io.Reader) (string, error) {
196 if _, err := io.Copy(io.Discard, data); err != nil {
197 return "", err
198 }
199 return "/backups/shop/users/hourly/file.bak", nil
200 }).Once()
201 storage.EXPECT().ApplyRetention(mock.Anything, "shop", mock.Anything).Return(0, nil).Once()
202
203 require.NoError(t, svc.RunForSchedule(ctx, domain.ScheduleHourly))
204}
205
206// TestService_RunForSchedule_RejectsUnknownSchedule proves a caller cannot
207// invent a schedule tier.
208func TestService_RunForSchedule_RejectsUnknownSchedule(t *testing.T) {
209 state := outmocks.NewMockAppStateReader(t)
210 _, _, svc := backupTestService(t, state)
211 require.Error(t, svc.RunForSchedule(context.Background(), domain.BackupSchedule("sometimes")))
212}
213
214// TestService_ListBackupsUsesAppIdentity proves listing is app-keyed and
215// never domain-keyed.
216func TestService_ListBackupsUsesAppIdentity(t *testing.T) {
217 ctx := context.Background()
218 state := outmocks.NewMockAppStateReader(t)
219 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
220
221 _, storage, svc := backupTestService(t, state)
222 storage.EXPECT().List(mock.Anything, "shop", mock.Anything).Return([]domain.BackupJob{{
223 ID: "job-1", App: "shop", Service: "api", DBName: "orders",
224 Status: domain.BackupStatusCompleted, StartedAt: time.Now().UTC(),
225 }}, nil).Once()
226
227 jobs, err := svc.ListBackups(ctx, "")
228 require.NoError(t, err)
229 require.Len(t, jobs, 1)
230 assert.Equal(t, "shop", jobs[0].App)
231}