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
93func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
94
95func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
96 f.retention = append(f.retention, app)
97 return 0, nil
98}
99
100// volumeSpec declares one volume and references it from the service's
101// backup declaration.
102func volumeSpec(service, volume, path string) domain.AppService {
103 return domain.AppService{
104 Name: service,
105 Image: "app:1",
106 Volumes: []domain.AppVolume{{Name: volume, Path: path}},
107 Backup: domain.AppBackup{Volume: []string{volume}},
108 }
109}
110
111func volumeTestService(t *testing.T, state *outmocks.MockAppStateReader, exporter *fakeVolumeArchiveExporter, storage *fakeVolumeBackupStorage, enabled bool) *VolumeService {
112 t.Helper()
113 return NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
114 Enabled: enabled,
115 Compression: domain.VolumeBackupCompressionGzip,
116 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
117 Timeout: time.Minute,
118 HelperImage: "helper:latest",
119 }, testLogger()).WithAppState(state)
120}
121
122// TestVolumeService_TargetsResolveFromDeclarations proves a volume target
123// comes from the ACTIVE declaration, with the runtime volume name and
124// mount path the exporter needs. No container label is consulted.
125func TestVolumeService_TargetsResolveFromDeclarations(t *testing.T) {
126 ctx := context.Background()
127 state := outmocks.NewMockAppStateReader(t)
128 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
129 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
130 "api": volumeSpec("api", "data", "/var/lib/data"),
131 }, "ctr-api"), true, nil).Once()
132
133 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
134 targets, err := svc.VolumeTargets(ctx, "shop")
135 require.NoError(t, err)
136 require.Len(t, targets, 1)
137 assert.Equal(t, "shop", targets[0].App)
138 assert.Equal(t, "api", targets[0].Service)
139 assert.Equal(t, "data", targets[0].VolumeName)
140 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), targets[0].RuntimeVolumeName)
141 assert.Equal(t, "/var/lib/data", targets[0].MountPath)
142}
143
144// TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume proves the
145// archive export targets the runtime volume and the artifact is stored
146// under the app identity.
147func TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume(t *testing.T) {
148 ctx := context.Background()
149 state := outmocks.NewMockAppStateReader(t)
150 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
151 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
152 "api": volumeSpec("api", "data", "/var/lib/data"),
153 }, "ctr-api"), true, nil).Once()
154
155 exporter := &fakeVolumeArchiveExporter{}
156 storage := &fakeVolumeBackupStorage{}
157 svc := volumeTestService(t, state, exporter, storage, true)
158
159 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
160 require.NoError(t, err)
161 require.Len(t, jobs, 1)
162 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
163 assert.Equal(t, "shop", jobs[0].App)
164 assert.Equal(t, "api", jobs[0].Service)
165 assert.Equal(t, "data", jobs[0].VolumeName)
166 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
167
168 require.Len(t, exporter.requested, 1)
169 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), exporter.requested[0].VolumeName)
170 assert.Equal(t, "/var/lib/data", exporter.requested[0].MountPath)
171 require.Len(t, storage.stored, 1)
172 assert.Equal(t, "shop", storage.stored[0].App)
173 assert.Equal(t, []string{"shop"}, storage.retention)
174 assert.Contains(t, jobs[0].ArtifactRef, "shop")
175}
176
177// TestVolumeService_RunVolumeBackups_SelectorRules proves the explicit
178// selector rules for volume backups too.
179func TestVolumeService_RunVolumeBackups_SelectorRules(t *testing.T) {
180 ctx := context.Background()
181 state := outmocks.NewMockAppStateReader(t)
182 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil)
183 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
184 "api": volumeSpec("api", "data", "/data"),
185 "web": volumeSpec("web", "uploads", "/uploads"),
186 }, "ctr-x"), true, nil)
187
188 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
189
190 _, err := svc.RunVolumeBackups(ctx, "shop", "", "")
191 require.Error(t, err)
192 assert.Contains(t, err.Error(), "ambiguous")
193 assert.Contains(t, err.Error(), "api/data")
194 assert.Contains(t, err.Error(), "web/uploads")
195
196 _, err = svc.RunVolumeBackups(ctx, "shop", "api", "missing")
197 require.Error(t, err)
198 assert.Contains(t, err.Error(), "not found")
199}
200
201// TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget
202// proves the scheduled pass enumerates declared targets and applies
203// retention per app.
204func TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget(t *testing.T) {
205 ctx := context.Background()
206 state := outmocks.NewMockAppStateReader(t)
207 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
208 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
209 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
210 "api": volumeSpec("api", "data", "/data"),
211 }, "ctr-api"), true, nil).Once()
212
213 exporter := &fakeVolumeArchiveExporter{}
214 storage := &fakeVolumeBackupStorage{}
215 svc := volumeTestService(t, state, exporter, storage, true)
216
217 require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
218 require.Len(t, storage.stored, 1)
219 assert.Equal(t, "data", storage.stored[0].VolumeName)
220 assert.Equal(t, []string{"shop"}, storage.retention)
221}
222
223// TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency proves
224// the scheduled pass runs targets in parallel, never more than
225// MaxConcurrency at once, and still backs up every target.
226func TestVolumeService_RunVolumeBackupsForSchedule_HonorsMaxConcurrency(t *testing.T) {
227 for _, limit := range []int{1, 2} {
228 t.Run(fmt.Sprintf("max_concurrency=%d", limit), func(t *testing.T) {
229 ctx := context.Background()
230 state := outmocks.NewMockAppStateReader(t)
231 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
232 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
233 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
234 "a": volumeSpec("a", "data-a", "/a"),
235 "b": volumeSpec("b", "data-b", "/b"),
236 "c": volumeSpec("c", "data-c", "/c"),
237 }, "ctr"), true, nil).Once()
238
239 hold := make(chan struct{})
240 exporter := &fakeVolumeArchiveExporter{hold: hold}
241 storage := &fakeVolumeBackupStorage{}
242 svc := NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
243 Enabled: true,
244 Compression: domain.VolumeBackupCompressionGzip,
245 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
246 Timeout: time.Minute,
247 MaxConcurrency: limit,
248 HelperImage: "helper:latest",
249 }, testLogger()).WithAppState(state)
250
251 done := make(chan error, 1)
252 go func() { done <- svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily) }()
253
254 // Wait until the limit is reached, then confirm it is never exceeded.
255 require.Eventually(t, func() bool {
256 exporter.mu.Lock()
257 defer exporter.mu.Unlock()
258 return exporter.inFlight == limit
259 }, 2*time.Second, time.Millisecond)
260 time.Sleep(20 * time.Millisecond)
261 close(hold)
262
263 require.NoError(t, <-done)
264 assert.Equal(t, limit, exporter.peak)
265 assert.Len(t, storage.stored, 3)
266 assert.Equal(t, []string{"shop"}, storage.retention)
267 })
268 }
269}
270
271// TestVolumeService_DisabledDoesNothing proves a disabled backup
272// configuration runs nothing at all.
273func TestVolumeService_DisabledDoesNothing(t *testing.T) {
274 state := outmocks.NewMockAppStateReader(t)
275 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, false)
276
277 jobs, err := svc.RunVolumeBackups(context.Background(), "shop", "", "")
278 require.NoError(t, err)
279 assert.Empty(t, jobs)
280 require.NoError(t, svc.RunVolumeBackupsForSchedule(context.Background(), domain.ScheduleDaily))
281}
282
283// TestVolumeService_ListVolumeBackupsWrapsStorageError proves storage
284// failures are reported with context.
285func TestVolumeService_ListVolumeBackupsWrapsStorageError(t *testing.T) {
286 state := outmocks.NewMockAppStateReader(t)
287 storage := &fakeVolumeBackupStorage{listErr: errors.New("boom")}
288 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true)
289
290 _, err := svc.ListVolumeBackups(context.Background(), "shop")
291 require.Error(t, err)
292 assert.Contains(t, err.Error(), "list volume archives")
293}
294
295// TestVolumeService_FailedExportReportsTheJob proves a failed export keeps
296// a failed job with its error and stores nothing.
297func TestVolumeService_FailedExportReportsTheJob(t *testing.T) {
298 ctx := context.Background()
299 state := outmocks.NewMockAppStateReader(t)
300 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
301 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
302 "api": volumeSpec("api", "data", "/data"),
303 }, "ctr-api"), true, nil).Once()
304
305 storage := &fakeVolumeBackupStorage{}
306 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: errors.New("helper failed")}, storage, true)
307
308 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
309 require.Error(t, err)
310 require.Len(t, jobs, 1)
311 assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
312 assert.Contains(t, jobs[0].Error, "helper failed")
313 assert.Empty(t, storage.stored)
314}