v3.3.0 b164fe9

volume_service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close