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
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}

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