1package backup
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "testing"
9 "time"
10
11 "github.com/bnema/zerowrap"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17 "github.com/bnema/gordon/internal/domain"
18)
19
20func testLogger() zerowrap.Logger {
21 return zerowrap.New(zerowrap.Config{Level: "disabled", Output: io.Discard})
22}
23
24type fakeVolumeArchiveExporter struct {
25 err error
26 requested []domain.VolumeArchiveRequest
27}
28
29func (f *fakeVolumeArchiveExporter) ExportVolumeArchive(_ context.Context, request domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
30 f.requested = append(f.requested, request)
31 if f.err != nil {
32 return nil, f.err
33 }
34 return &domain.VolumeArchiveResult{Stream: io.NopCloser(bytes.NewReader([]byte("archive")))}, nil
35}
36
37type fakeVolumeBackupStorage struct {
38 stored []domain.VolumeBackupJob
39 retention []string
40 jobs []domain.VolumeBackupJob
41 listErr error
42 storeErr error
43}
44
45func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
46 if _, err := io.Copy(io.Discard, data); err != nil {
47 return "", err
48 }
49 if f.storeErr != nil {
50 return "", f.storeErr
51 }
52 f.stored = append(f.stored, job)
53 return "s3://bucket/" + job.App + "/" + job.VolumeName, nil
54}
55
56func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
57 return io.NopCloser(bytes.NewReader(nil)), nil
58}
59
60func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
61 if f.listErr != nil {
62 return nil, f.listErr
63 }
64 return f.jobs, nil
65}
66
67func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
68
69func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, app string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
70 f.retention = append(f.retention, app)
71 return 0, nil
72}
73
74// volumeSpec declares one volume and references it from the service's
75// backup declaration.
76func volumeSpec(service, volume, path string) domain.AppService {
77 return domain.AppService{
78 Name: service,
79 Image: "app:1",
80 Volumes: []domain.AppVolume{{Name: volume, Path: path}},
81 Backup: domain.AppBackup{Volume: []string{volume}},
82 }
83}
84
85func volumeTestService(t *testing.T, state *outmocks.MockAppStateReader, exporter *fakeVolumeArchiveExporter, storage *fakeVolumeBackupStorage, enabled bool) *VolumeService {
86 t.Helper()
87 return NewVolumeService(exporter, storage, domain.VolumeBackupConfig{
88 Enabled: enabled,
89 Compression: domain.VolumeBackupCompressionGzip,
90 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
91 Timeout: time.Minute,
92 HelperImage: "helper:latest",
93 }, testLogger()).WithAppState(state)
94}
95
96// TestVolumeService_TargetsResolveFromDeclarations proves a volume target
97// comes from the ACTIVE declaration, with the runtime volume name and
98// mount path the exporter needs. No container label is consulted.
99func TestVolumeService_TargetsResolveFromDeclarations(t *testing.T) {
100 ctx := context.Background()
101 state := outmocks.NewMockAppStateReader(t)
102 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
103 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
104 "api": volumeSpec("api", "data", "/var/lib/data"),
105 }, "ctr-api"), true, nil).Once()
106
107 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
108 targets, err := svc.VolumeTargets(ctx, "shop")
109 require.NoError(t, err)
110 require.Len(t, targets, 1)
111 assert.Equal(t, "shop", targets[0].App)
112 assert.Equal(t, "api", targets[0].Service)
113 assert.Equal(t, "data", targets[0].VolumeName)
114 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), targets[0].RuntimeVolumeName)
115 assert.Equal(t, "/var/lib/data", targets[0].MountPath)
116}
117
118// TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume proves the
119// archive export targets the runtime volume and the artifact is stored
120// under the app identity.
121func TestVolumeService_RunVolumeBackups_ExportsTheDeclaredVolume(t *testing.T) {
122 ctx := context.Background()
123 state := outmocks.NewMockAppStateReader(t)
124 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
125 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
126 "api": volumeSpec("api", "data", "/var/lib/data"),
127 }, "ctr-api"), true, nil).Once()
128
129 exporter := &fakeVolumeArchiveExporter{}
130 storage := &fakeVolumeBackupStorage{}
131 svc := volumeTestService(t, state, exporter, storage, true)
132
133 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
134 require.NoError(t, err)
135 require.Len(t, jobs, 1)
136 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
137 assert.Equal(t, "shop", jobs[0].App)
138 assert.Equal(t, "api", jobs[0].Service)
139 assert.Equal(t, "data", jobs[0].VolumeName)
140 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
141
142 require.Len(t, exporter.requested, 1)
143 assert.Equal(t, domain.RuntimeVolumeName("shop", "api", "data"), exporter.requested[0].VolumeName)
144 assert.Equal(t, "/var/lib/data", exporter.requested[0].MountPath)
145 require.Len(t, storage.stored, 1)
146 assert.Equal(t, "shop", storage.stored[0].App)
147 assert.Equal(t, []string{"shop"}, storage.retention)
148 assert.Contains(t, jobs[0].ArtifactRef, "shop")
149}
150
151// TestVolumeService_RunVolumeBackups_SelectorRules proves the explicit
152// selector rules for volume backups too.
153func TestVolumeService_RunVolumeBackups_SelectorRules(t *testing.T) {
154 ctx := context.Background()
155 state := outmocks.NewMockAppStateReader(t)
156 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil)
157 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
158 "api": volumeSpec("api", "data", "/data"),
159 "web": volumeSpec("web", "uploads", "/uploads"),
160 }, "ctr-x"), true, nil)
161
162 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, true)
163
164 _, err := svc.RunVolumeBackups(ctx, "shop", "", "")
165 require.Error(t, err)
166 assert.Contains(t, err.Error(), "ambiguous")
167 assert.Contains(t, err.Error(), "api/data")
168 assert.Contains(t, err.Error(), "web/uploads")
169
170 _, err = svc.RunVolumeBackups(ctx, "shop", "api", "missing")
171 require.Error(t, err)
172 assert.Contains(t, err.Error(), "not found")
173}
174
175// TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget
176// proves the scheduled pass enumerates declared targets and applies
177// retention per app.
178func TestVolumeService_RunVolumeBackupsForSchedule_RunsEveryDeclaredTarget(t *testing.T) {
179 ctx := context.Background()
180 state := outmocks.NewMockAppStateReader(t)
181 state.EXPECT().ListApps(mock.Anything).Return([]string{"shop"}, nil).Once()
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{
184 "api": volumeSpec("api", "data", "/data"),
185 }, "ctr-api"), true, nil).Once()
186
187 exporter := &fakeVolumeArchiveExporter{}
188 storage := &fakeVolumeBackupStorage{}
189 svc := volumeTestService(t, state, exporter, storage, true)
190
191 require.NoError(t, svc.RunVolumeBackupsForSchedule(ctx, domain.ScheduleDaily))
192 require.Len(t, storage.stored, 1)
193 assert.Equal(t, "data", storage.stored[0].VolumeName)
194 assert.Equal(t, []string{"shop"}, storage.retention)
195}
196
197// TestVolumeService_DisabledDoesNothing proves a disabled backup
198// configuration runs nothing at all.
199func TestVolumeService_DisabledDoesNothing(t *testing.T) {
200 state := outmocks.NewMockAppStateReader(t)
201 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, false)
202
203 jobs, err := svc.RunVolumeBackups(context.Background(), "shop", "", "")
204 require.NoError(t, err)
205 assert.Empty(t, jobs)
206 require.NoError(t, svc.RunVolumeBackupsForSchedule(context.Background(), domain.ScheduleDaily))
207}
208
209// TestVolumeService_ListVolumeBackupsWrapsStorageError proves storage
210// failures are reported with context.
211func TestVolumeService_ListVolumeBackupsWrapsStorageError(t *testing.T) {
212 state := outmocks.NewMockAppStateReader(t)
213 storage := &fakeVolumeBackupStorage{listErr: errors.New("boom")}
214 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{}, storage, true)
215
216 _, err := svc.ListVolumeBackups(context.Background(), "shop")
217 require.Error(t, err)
218 assert.Contains(t, err.Error(), "list volume archives")
219}
220
221// TestVolumeService_FailedExportReportsTheJob proves a failed export keeps
222// a failed job with its error and stores nothing.
223func TestVolumeService_FailedExportReportsTheJob(t *testing.T) {
224 ctx := context.Background()
225 state := outmocks.NewMockAppStateReader(t)
226 state.EXPECT().LoadIntent(mock.Anything, "shop").Return(domain.AppStopIntent{App: "shop"}, nil).Once()
227 state.EXPECT().LoadActive(mock.Anything, "shop").Return(activeWith("shop", map[string]domain.AppService{
228 "api": volumeSpec("api", "data", "/data"),
229 }, "ctr-api"), true, nil).Once()
230
231 storage := &fakeVolumeBackupStorage{}
232 svc := volumeTestService(t, state, &fakeVolumeArchiveExporter{err: errors.New("helper failed")}, storage, true)
233
234 jobs, err := svc.RunVolumeBackups(ctx, "shop", "api", "data")
235 require.Error(t, err)
236 require.Len(t, jobs, 1)
237 assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
238 assert.Contains(t, jobs[0].Error, "helper failed")
239 assert.Empty(t, storage.stored)
240}