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

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