v3.5.0 8345e76

volume_service_test.go RawHistoryFolder

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}

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