1package backup
2
3import (
4 "bytes"
5 "context"
6 "fmt"
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}
27
28func (f fakeVolumeArchiveExporter) ExportVolumeArchive(context.Context, domain.VolumeArchiveRequest) (*domain.VolumeArchiveResult, error) {
29 if f.err != nil {
30 return nil, f.err
31 }
32 return &domain.VolumeArchiveResult{Stream: io.NopCloser(bytes.NewReader([]byte("archive")))}, nil
33}
34
35type fakeVolumeBackupStorage struct {
36 stored []domain.VolumeBackupJob
37 retention []string
38 listErr error
39}
40
41func (f *fakeVolumeBackupStorage) StoreVolumeArchive(_ context.Context, job domain.VolumeBackupJob, data io.Reader) (string, error) {
42 if _, err := io.Copy(io.Discard, data); err != nil {
43 return "", err
44 }
45 f.stored = append(f.stored, job)
46 return "s3://bucket/" + job.VolumeName, nil
47}
48
49func (f *fakeVolumeBackupStorage) GetVolumeArchive(context.Context, string) (io.ReadCloser, error) {
50 return io.NopCloser(bytes.NewReader(nil)), nil
51}
52
53func (f *fakeVolumeBackupStorage) ListVolumeArchives(context.Context, string) ([]domain.VolumeBackupJob, error) {
54 if f.listErr != nil {
55 return nil, f.listErr
56 }
57 return nil, nil
58}
59
60func (f *fakeVolumeBackupStorage) DeleteVolumeArchive(context.Context, string) error { return nil }
61
62func (f *fakeVolumeBackupStorage) ApplyVolumeRetention(_ context.Context, domainName string, _ domain.VolumeBackupRetentionPolicy) (int, error) {
63 f.retention = append(f.retention, domainName)
64 return 0, nil
65}
66
67func TestVolumeServiceRunVolumeBackups(t *testing.T) {
68 runtime := outmocks.NewMockContainerRuntime(t)
69 storage := &fakeVolumeBackupStorage{}
70 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, storage, domain.VolumeBackupConfig{
71 Enabled: true,
72 Compression: domain.VolumeBackupCompressionGzip,
73 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
74 Timeout: time.Minute,
75 MaxConcurrency: 1,
76 HelperImage: "helper:latest",
77 VolumePrefix: "gordon",
78 }, testLogger())
79
80 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
81 {
82 ID: "app",
83 Name: "app",
84 Labels: map[string]string{
85 domain.LabelManaged: "true",
86 domain.LabelDomain: "app.example.com",
87 },
88 VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
89 },
90 }, nil)
91
92 jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
93 require.NoError(t, err)
94 require.Len(t, jobs, 1)
95 assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
96 assert.Equal(t, "s3://bucket/gordon-app-data", jobs[0].ArtifactRef)
97 assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
98 assert.Len(t, storage.stored, 1)
99 assert.Equal(t, []string{"app.example.com"}, storage.retention)
100}
101
102func TestVolumeServiceRunVolumeBackupsReturnsPartialFailure(t *testing.T) {
103 runtime := outmocks.NewMockContainerRuntime(t)
104 storage := &fakeVolumeBackupStorage{}
105 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{err: fmt.Errorf("boom")}, storage, domain.VolumeBackupConfig{
106 Enabled: true,
107 Compression: domain.VolumeBackupCompressionGzip,
108 Retention: domain.VolumeBackupRetentionPolicy{Keep: 2},
109 Timeout: time.Minute,
110 MaxConcurrency: 1,
111 HelperImage: "helper:latest",
112 VolumePrefix: "gordon",
113 }, testLogger())
114
115 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
116 {
117 ID: "app",
118 Name: "app",
119 Labels: map[string]string{
120 domain.LabelManaged: "true",
121 domain.LabelDomain: "app.example.com",
122 },
123 VolumeMounts: []domain.ContainerVolumeMount{{Name: "gordon-app-data", Type: "volume", Destination: "/data"}},
124 },
125 }, nil)
126
127 jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
128 require.Error(t, err)
129 require.Len(t, jobs, 1)
130 assert.Equal(t, domain.BackupStatusFailed, jobs[0].Status)
131 assert.Contains(t, jobs[0].Error, "boom")
132 assert.Empty(t, storage.stored)
133}
134
135func TestVolumeServiceListVolumeBackupsWrapsStorageError(t *testing.T) {
136 storageErr := fmt.Errorf("s3 unavailable")
137 svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
138
139 jobs, err := svc.ListVolumeBackups(context.Background(), "app.example.com")
140
141 require.ErrorIs(t, err, storageErr)
142 assert.Contains(t, err.Error(), "list volume archives")
143 assert.Nil(t, jobs)
144}
145
146func TestVolumeServiceStatusWrapsStorageError(t *testing.T) {
147 storageErr := fmt.Errorf("s3 unavailable")
148 svc := NewVolumeService(outmocks.NewMockContainerRuntime(t), fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{listErr: storageErr}, domain.VolumeBackupConfig{}, testLogger())
149
150 jobs, err := svc.VolumeBackupStatus(context.Background())
151
152 require.ErrorIs(t, err, storageErr)
153 assert.Contains(t, err.Error(), "list volume backup status")
154 assert.Nil(t, jobs)
155}
156
157func TestVolumeServiceDisabledDoesNothing(t *testing.T) {
158 runtime := outmocks.NewMockContainerRuntime(t)
159 svc := NewVolumeService(runtime, fakeVolumeArchiveExporter{}, &fakeVolumeBackupStorage{}, domain.VolumeBackupConfig{}, testLogger())
160
161 jobs, err := svc.RunVolumeBackups(context.Background(), "", "")
162
163 require.NoError(t, err)
164 assert.Empty(t, jobs)
165}