volume_backup_storage_test.go RawHistoryFolder

1package s3
2
3import (
4	"bytes"
5	"context"
6	"io"
7	"sort"
8	"testing"
9	"time"
10
11	"github.com/aws/aws-sdk-go-v2/aws"
12	"github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager"
13	awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
14	awss3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
15	"github.com/stretchr/testify/assert"
16	"github.com/stretchr/testify/require"
17
18	"github.com/bnema/gordon/internal/domain"
19)
20
21type fakeVolumeS3Client struct {
22	objects  map[string][]byte
23	metadata map[string]map[string]string
24	deleted  []string
25	heads    int
26}
27
28func newFakeVolumeS3Client() *fakeVolumeS3Client {
29	return &fakeVolumeS3Client{objects: make(map[string][]byte), metadata: make(map[string]map[string]string)}
30}
31
32func (f *fakeVolumeS3Client) GetObject(_ context.Context, params *awss3.GetObjectInput, _ ...func(*awss3.Options)) (*awss3.GetObjectOutput, error) {
33	data := f.objects[aws.ToString(params.Key)]
34	return &awss3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(data))}, nil
35}
36
37func (f *fakeVolumeS3Client) HeadObject(_ context.Context, params *awss3.HeadObjectInput, _ ...func(*awss3.Options)) (*awss3.HeadObjectOutput, error) {
38	f.heads++
39	return &awss3.HeadObjectOutput{Metadata: f.metadata[aws.ToString(params.Key)]}, nil
40}
41
42func (f *fakeVolumeS3Client) ListObjectsV2(_ context.Context, params *awss3.ListObjectsV2Input, _ ...func(*awss3.Options)) (*awss3.ListObjectsV2Output, error) {
43	prefix := aws.ToString(params.Prefix)
44	keys := make([]string, 0)
45	for key := range f.objects {
46		if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
47			keys = append(keys, key)
48		}
49	}
50	sort.Strings(keys)
51	contents := make([]awss3types.Object, 0, len(keys))
52	for _, key := range keys {
53		size := int64(len(f.objects[key]))
54		contents = append(contents, awss3types.Object{Key: aws.String(key), Size: aws.Int64(size)})
55	}
56	return &awss3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(false)}, nil
57}
58
59func (f *fakeVolumeS3Client) DeleteObject(_ context.Context, params *awss3.DeleteObjectInput, _ ...func(*awss3.Options)) (*awss3.DeleteObjectOutput, error) {
60	key := aws.ToString(params.Key)
61	delete(f.objects, key)
62	f.deleted = append(f.deleted, key)
63	return &awss3.DeleteObjectOutput{}, nil
64}
65
66type fakeVolumeUploader struct {
67	client *fakeVolumeS3Client
68	last   *transfermanager.UploadObjectInput
69}
70
71func (f *fakeVolumeUploader) UploadObject(_ context.Context, input *transfermanager.UploadObjectInput, _ ...func(*transfermanager.Options)) (*transfermanager.UploadObjectOutput, error) {
72	data, err := io.ReadAll(input.Body)
73	if err != nil {
74		return nil, err
75	}
76	key := aws.ToString(input.Key)
77	f.client.objects[key] = data
78	f.client.metadata[key] = input.Metadata
79	f.last = input
80	return &transfermanager.UploadObjectOutput{Key: input.Key}, nil
81}
82
83func TestVolumeBackupStorageStoreListGet(t *testing.T) {
84	client := newFakeVolumeS3Client()
85	uploader := &fakeVolumeUploader{client: client}
86	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
87		S3Bucket: "gordon-backups",
88		S3Prefix: "/prod/gordon/",
89	}, client, uploader)
90	started := time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC)
91
92	artifact, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
93		ID:            "job/1",
94		App:           "app.example.com",
95		Service:       "api",
96		ContainerName: "app",
97		VolumeName:    "gordon-app-data",
98		MountPath:     "/data",
99		StartedAt:     started,
100		Metadata:      map[string]string{"compression": string(domain.VolumeBackupCompressionZstd)},
101	}, bytes.NewReader([]byte("archive")))
102	require.NoError(t, err)
103
104	assert.Equal(t, "s3://gordon-backups/prod/gordon/apps/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
105	assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
106	assert.Empty(t, uploader.last.StorageClass, "no storage class keeps the bucket default")
107
108	jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
109	require.NoError(t, err)
110	require.Len(t, jobs, 1)
111	assert.Equal(t, domain.BackupStatusCompleted, jobs[0].Status)
112	assert.Equal(t, "api", jobs[0].Service)
113	assert.Equal(t, "app", jobs[0].ContainerName)
114	assert.Equal(t, "/data", jobs[0].MountPath)
115	assert.Equal(t, string(domain.VolumeBackupCompressionZstd), jobs[0].Metadata["compression"])
116	assert.Equal(t, int64(len("archive")), jobs[0].SizeBytes)
117	assert.Equal(t, artifact, jobs[0].ArtifactRef)
118
119	rc, err := storage.GetVolumeArchive(context.Background(), artifact)
120	require.NoError(t, err)
121	defer rc.Close()
122	data, err := io.ReadAll(rc)
123	require.NoError(t, err)
124	assert.Equal(t, []byte("archive"), data)
125}
126
127func TestVolumeBackupStorageSendsConfiguredStorageClass(t *testing.T) {
128	client := newFakeVolumeS3Client()
129	uploader := &fakeVolumeUploader{client: client}
130	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{
131		S3Bucket:       "bucket",
132		S3StorageClass: "ONEZONE_IA",
133	}, client, uploader)
134
135	_, err := storage.StoreVolumeArchive(context.Background(), domain.VolumeBackupJob{
136		ID:         "job",
137		App:        "app",
138		VolumeName: "data",
139		StartedAt:  time.Date(2026, 6, 19, 2, 0, 0, 0, time.UTC),
140	}, bytes.NewReader([]byte("archive")))
141	require.NoError(t, err)
142
143	assert.Equal(t, "ONEZONE_IA", string(uploader.last.StorageClass))
144}
145
146// TestVolumeBackupStorageRetentionMixesLongAndShortIDs proves archives
147// written with the former timestamped job IDs stay listed and age out
148// under retention next to archives with short IDs.
149func TestVolumeBackupStorageRetentionMixesLongAndShortIDs(t *testing.T) {
150	client := newFakeVolumeS3Client()
151	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, &fakeVolumeUploader{client: client})
152	longID := "gordon/apps/forgejo/volumes/forgejo-data/20261017T052537Z-2026-10-17T05_25_37.725487298Z-7cddcd8f.tar.gz"
153	short1 := "gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-1a2b3c4d.tar.gz"
154	short2 := "gordon/apps/forgejo/volumes/forgejo-data/20261019T013000Z-5e6f7a8b.tar.gz"
155	for _, key := range []string{longID, short1, short2} {
156		client.objects[key] = []byte("x")
157	}
158
159	jobs, err := storage.ListVolumeArchives(context.Background(), "forgejo")
160	require.NoError(t, err)
161	require.Len(t, jobs, 3)
162	assert.Equal(t, "5e6f7a8b", jobs[0].ID)
163	assert.Equal(t, "2026-10-17T05_25_37.725487298Z-7cddcd8f", jobs[2].ID)
164
165	deleted, err := storage.ApplyVolumeRetention(context.Background(), "forgejo", domain.VolumeBackupRetentionPolicy{Keep: 2})
166	require.NoError(t, err)
167	assert.Equal(t, 1, deleted)
168	assert.Equal(t, []string{longID}, client.deleted)
169}
170
171// TestVolumeBackupStorageLatestVolumeArchives proves the newest archive of
172// each app volume is selected from the listing alone, with no per-object
173// metadata request.
174func TestVolumeBackupStorageLatestVolumeArchives(t *testing.T) {
175	client := newFakeVolumeS3Client()
176	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket", S3Prefix: "gordon"}, client, nil)
177	for key, data := range map[string]string{
178		"gordon/apps/forgejo/volumes/forgejo-data/20261017T013000Z-1a2b3c4d.tar.gz":  "old",
179		"gordon/apps/forgejo/volumes/forgejo-data/20261018T013000Z-5e6f7a8b.tar.gz":  "newest",
180		"gordon/apps/forgejo/volumes/postgres-data/20261016T013000Z-0a0b0c0d.tar.gz": "pg",
181		"gordon/apps/ermite/volumes/forgejo-data/20261015T013000Z-9a9b9c9d.tar.gz":   "other-app",
182	} {
183		client.objects[key] = []byte(data)
184	}
185
186	jobs, err := storage.LatestVolumeArchives(context.Background())
187	require.NoError(t, err)
188	assert.Equal(t, 0, client.heads)
189
190	got := map[string]domain.VolumeBackupJob{}
191	for _, job := range jobs {
192		got[job.App+"/"+job.VolumeName] = job
193	}
194	require.Len(t, got, 3)
195	newest := got["forgejo/forgejo-data"]
196	assert.Equal(t, "5e6f7a8b", newest.ID)
197	assert.Equal(t, int64(len("newest")), newest.SizeBytes)
198	assert.Equal(t, time.Date(2026, 10, 18, 1, 30, 0, 0, time.UTC), newest.StartedAt)
199	assert.Contains(t, got, "forgejo/postgres-data")
200	assert.Contains(t, got, "ermite/forgejo-data")
201}
202
203func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
204	client := newFakeVolumeS3Client()
205	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
206	keys := []string{
207		"apps/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst",
208		"apps/app.example.com/volumes/gordon-data/20260619T020000Z-b.tar.zst",
209		"apps/app.example.com/volumes/gordon-data/20260619T030000Z-c.tar.zst",
210		"apps/app.example.com/volumes/gordon-data/not-a-backup.tar.zst",
211	}
212	for _, key := range keys {
213		client.objects[key] = []byte("x")
214	}
215
216	deleted, err := storage.ApplyVolumeRetention(context.Background(), "app.example.com", domain.VolumeBackupRetentionPolicy{Keep: 2})
217	require.NoError(t, err)
218
219	assert.Equal(t, 1, deleted)
220	assert.Equal(t, []string{"apps/app.example.com/volumes/gordon-data/20260619T010000Z-a.tar.zst"}, client.deleted)
221	assert.Contains(t, client.objects, "apps/app.example.com/volumes/gordon-data/not-a-backup.tar.zst")
222}
223
224func TestVolumeBackupStorageRejectsWrongBucketArtifact(t *testing.T) {
225	storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "expected"}, newFakeVolumeS3Client(), nil)
226
227	_, err := storage.GetVolumeArchive(context.Background(), "s3://other/key")
228
229	require.Error(t, err)
230	assert.Contains(t, err.Error(), "does not match")
231}

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