1package backup
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "fmt"
8 "io"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"
17
18 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
19 "github.com/bnema/gordon/internal/domain"
20)
21
22func testLogger() zerowrap.Logger {
23 return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard})
24}
25
26type fakeVolumeArchiveExporter struct {
27 mu sync.Mutex
28 err error
29 requested []domain.VolumeArchiveRequest
30 // hold, when set, keeps each export open until released so tests can
31 // observe how many run at once.
32 hold chan struct{}
33 inFlight int
34 peak int
35}
36
37func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, request domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
38 f.mu.Lock()
39 f.requested = append(f.requested, request)
40 f.inFlight++
41 if f.inFlight > f.peak {
42 f.peak = f.inFlight
43 }
44 hold := f.hold
45 f.mu.Unlock()
46 defer func() {
47 f.mu.Lock()
48 f.inFlight--
49 f.mu.Unlock()
50 }()
51 if hold != nil {
52 <-hold
53 }
54 if f.err != nil {
55 return nil, f.err
56 }
57 return &domain.VolumeArchiveResult{Stream: io.NopCloser(bytes.NewReader([]byte("archive")))}, nil
58}
59
60type fakeVolumeBackupStorage struct {
61 mu sync.Mutex
62 stored []domain.VolumeBackupJob
63 retention []string
64 jobs []domain.VolumeBackupJob
65 listErr error
66 storeErr error
67}
68
69func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
70 if _, err := io.Copy(io.Discard, data); err != nil {
71 return "", err
72 }
73 if f.storeErr != nil {
74 return "", f.storeErr
75 }
76 f.mu.Lock()
77 f.stored = append(f.stored, job)
78 f.mu.Unlock()
79 return "s3://bucket/" + job.App + "/" + job.VolumeName, nil
80}
81
82func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
83 return io.NopCloser(bytes.NewReader(nil)), nil
84}
85
86func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
87 if f.listErr != nil {
88 return nil, f.listErr
89 }
90 return f.jobs, nil
91}
92
93// LatestVolumeArchives returns the configured jobs as-is; the S3 adapter
94// owns the newest-per-volume selection.
95func (f *fakeVolumeBackupStorage) LatestVolumeArchives(context.Context) ([]domain.VolumeBackupJob, error) {
96 if f.listErr != nil {
97 return nil, f.listErr
98 }
99 return f.jobs, nil
100}
101
102func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
103
104func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
105 f.retention = append(f.retention, app)
106 return 0, nil
107}
108
109// volumeSpec declares one volume and references it from the service's
110// backup declaration.
111func volumeSpec(service, volume, path string) domain.AppService {
112 return domain.AppService{
113 Name: service,
114 Image: "app:1",
115 Volumes: []domain.AppVolume{{Name: volume, Path: path}},
116 Backup: domain.AppBackup{Volume: []string{volume}},
117 }
118}
119
120func volumeTestService(t *testing.T, state *outmocks.MockAppStateReader, exporter *fakeVolumeArchiveExporter, storage *fakeVolumeBackupStorage, enabled bool) *VolumeService {
121 t.Helper()
122 return NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
123 Enabled: enabled,
124 Compression: domain.VolumeBackupCompressionGzip,
125 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
126 Timeout: time.Minute,
127 HelperImage: "helper:latest",
128 }, testLogger()).WithAppState(state)
129}
130
131// TestVolumeService_TargetsResolveFromDeclarations proves a volume target
132// comes from the ACTIVE declaration, with the runtime volume name and
133// mount path the exporter needs. No container label is consulted.
134func TestVolumeService_TargetsResolveFromDeclarations(t *testing.T) {
135 ctx := context.Background()
136 state := outmocks.NewMockAppStateReader(t)
137 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
138 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
139 "api": volumeSpec("api", "data", "/var/lib/data"),
140 }, "ctr-api"), true, nil).Once()
141
142 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
143 targets, err := svc.VolumeTargets(ctx, "shop")
144 require.NoError(t, err)
145 require.Len(t, targets, 1)
146 assert.Equal(t, "shop", targets[0].App)
147 assert.Equal(t, "api", targets[0].Service)
148 assert.Equal(t, "data", targets[0].VolumeName)
149 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), targets[0].RuntimeVolumeName)
150 assert.Equal(t, "/var/lib/data", targets[0].MountPath)
151}
152
153// TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume proves the
154// archive export targets the runtime volume and the artifact is stored
155// under the app identity.
156func TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume(t *testing.T) {
157 ctx := context.Background()
158 state := outmocks.NewMockAppStateReader(t)
159 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
160 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
161 "api": volumeSpec("api", "data", "/var/lib/data"),
162 }, "ctr-api"), true, nil).Once()
163
164 exporter := &fakeVolumeArchiveExporter{}
165 storage := &fakeVolumeBackupStorage{}
166 svc := volumeTestService(t, state, exporter, storage, true)
167
168 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
169 require.NoError(t, err)
170 require.Len(t, jobs, 1)
171 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
172 assert.Equal(t, "shop", jobs[0].App)
173 assert.Equal(t, "api", jobs[0].Service)
174 assert.Equal(t, "data", jobs[0].VolumeName)
175 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
176
177 require.Len(t, exporter.requested, 1)
178 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), exporter.requested[0].VolumeName)
179 assert.Equal(t, "/var/lib/data", exporter.requested[0].MountPath)
180 require.Len(t, storage.stored, 1)
181 assert.Equal(t, "shop", storage.stored[0].App)
182 assert.Equal(t, []string{"shop"}, storage.retention)
183 assert.Contains(t, jobs[0].ArtifactRef, "shop")
184}
185
186// TestVolumeService_RunVolumeBackups_SelectorRules proves the explicit
187// selector rules for volume backups too.
188func TestVolumeService_RunVolumeBackups_SelectorRules(t *testing.T) {
189 ctx := context.Background()
190 state := outmocks.NewMockAppStateReader(t)
191 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil)
192 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
193 "api": volumeSpec("api", "data", "/data"),
194 "web": volumeSpec("web", "uploads", "/uploads"),
195 }, "ctr-x"), true, nil)
196
197 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
198
199 _, err := svc.RunVolumeBackups(ctx, "shop", "", "")
200 require.Error(t, err)
201 assert.Contains(t, err.Error(), "ambiguous")
202 assert.Contains(t, err.Error(), "api/data")
203 assert.Contains(t, err.Error(), "web/uploads")
204
205 _, err = svc.RunVolumeBackups(ctx, "shop", "api", "missing")
206 require.Error(t, err)
207 assert.Contains(t, err.Error(), "not found")
208}
209
210// TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget
211// proves the scheduled pass enumerates declared targets and applies
212// retention per app.
213func TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget(t *testing.T) {
214 ctx := context.Background()
215 state := outmocks.NewMockAppStateReader(t)
216 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
217 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
218 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
219 "api": volumeSpec("api", "data", "/data"),
220 }, "ctr-api"), true, nil).Once()
221
222 exporter := &fakeVolumeArchiveExporter{}
223 storage := &fakeVolumeBackupStorage{}
224 svc := volumeTestService(t, state, exporter, storage, true)
225
226 require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
227 require.Len(t, storage.stored, 1)
228 assert.Equal(t, "data", storage.stored[0].VolumeName)
229 assert.Equal(t, []string{"shop"}, storage.retention)
230}
231
232// TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency proves
233// the scheduled pass runs targets in parallel, never more than
234// MaxConcurrency at once, and still backs up every target.
235func TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency(t *testing.T) {
236 for _, limit := range []int{1, 2} {
237 t.Run(fmt.Sprintf("max_concurrency=%d", limit), func(t *testing.T) {
238 ctx := context.Background()
239 state := outmocks.NewMockAppStateReader(t)
240 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
241 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
242 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
243 "a": volumeSpec("a", "data-a", "/a"),
244 "b": volumeSpec("b", "data-b", "/b"),
245 "c": volumeSpec("c", "data-c", "/c"),
246 }, "ctr"), true, nil).Once()
247
248 hold := make(chan struct{})
249 exporter := &fakeVolumeArchiveExporter{hold: hold}
250 storage := &fakeVolumeBackupStorage{}
251 svc := NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
252 Enabled: true,
253 Compression: domain.VolumeBackupCompressionGzip,
254 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
255 Timeout: time.Minute,
256 MaxConcurrency: limit,
257 HelperImage: "helper:latest",
258 }, testLogger()).WithAppState(state)
259
260 done := make(chan error, 1)
261 go func() { done <- svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily) }()
262
263 // Wait until the limit is reached, then confirm it is never exceeded.
264 require.Eventually(t, func() bool {
265 exporter.mu.Lock()
266 defer exporter.mu.Unlock()
267 return exporter.inFlight == limit
268 }, 2*time.Second, time.Millisecond)
269 time.Sleep(20 * time.Millisecond)
270 close(hold)
271
272 require.NoError(t, <-done)
273 assert.Equal(t, limit, exporter.peak)
274 assert.Len(t, storage.stored, 3)
275 assert.Equal(t, []string{"shop"}, storage.retention)
276 })
277 }
278}
279
280// TestVolumeService_DisabledDoesNothing proves a disabled backup
281// configuration runs nothing at all.
282func TestVolumeService_DisabledDoesNothing(t *testing.T) {
283 state := outmocks.NewMockAppStateReader(t)
284 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, false)
285
286 jobs, err := svc.RunVolumeBackups(context.Background(), "shop", "", "")
287 require.NoError(t, err)
288 assert.Empty(t, jobs)
289 require.NoError(t, svc.RunVolumeBackupsForSchedule(context.Background(), domain.ScheduleDaily))
290}
291
292// TestVolumeService_ListVolumeBackupsWrapsStorageError proves storage
293// failures are reported with context.
294func TestVolumeService_ListVolumeBackupsWrapsStorageError(t *testing.T) {
295 state := outmocks.NewMockAppStateReader(t)
296 storage := &fakeVolumeBackupStorage{listErr: errors.New("boom")}
297 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true)
298
299 _, err := svc.ListVolumeBackups(context.Background(), "shop")
300 require.Error(t, err)
301 assert.Contains(t, err.Error(), "list volume archives")
302}
303
304// TestVolumeService_FailedExportReportsTheJob proves a failed export keeps
305// a failed job with its error and stores nothing.
306func TestVolumeService_FailedExportReportsTheJob(t *testing.T) {
307 ctx := context.Background()
308 state := outmocks.NewMockAppStateReader(t)
309 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
310 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
311 "api": volumeSpec("api", "data", "/data"),
312 }, "ctr-api"), true, nil).Once()
313
314 storage := &fakeVolumeBackupStorage{}
315 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: errors.New("helper failed")}, storage, true)
316
317 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
318 require.Error(t, err)
319 require.Len(t, jobs, 1)
320 assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
321 assert.Contains(t, jobs[0].Error, "helper failed")
322 assert.Empty(t, storage.stored)
323}