detached 8f20640

feat(backup): configurable S3 storage class for volume archives

backups.volumes.s3.storage_class is sent with every upload (single and
multipart), so archives can target cheaper classes such as Scaleway
ONEZONE_IA or AWS STANDARD_IA instead of the bucket default. The value is
normalized to upper case and limited to S3-style tokens; the provider
validates the class itself.
commit
8f20640aa4bda48b05a33cd5ce4aed222131a1a0
author
brice · · 2026-10-17 05:12 UTC
parent
31f1eec
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +79 -0
M docs/config/backups.md +6 -0
@@ -50,6 +50,7 @@ prefix = "prod/gordon"
5050 # path_style = false
5151 # sse_algorithm = "AES256"
5252 # sse_kms_key_id = ""
53+# storage_class = "ONEZONE_IA"
5354
5455 [backups.volumes.retention]
5556 keep = 14
@@ -68,6 +69,9 @@ keep = 14
6869 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
6970 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
7071 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
72+| `backups.volumes.s3.sse_algorithm` | `""` | Server-side encryption sent with each upload, such as `AES256` |
73+| `backups.volumes.s3.sse_kms_key_id` | `""` | KMS key ID for `aws:kms` encryption |
74+| `backups.volumes.s3.storage_class` | `""` | Storage class sent with each upload, such as `STANDARD_IA` (AWS) or `ONEZONE_IA` (Scaleway One Zone); empty uses the bucket default |
7175 | `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |
7276
7377 Volume backup objects are stored under the canonical app name:
@@ -86,6 +90,8 @@ the current layout.
8690 - Volume backups archive the app's declared volumes that its backup declaration references.
8791 - Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.
8892 - Database backups archive the app's declared PostgreSQL databases that its backup declaration references.
93+- Only volumes listed in a service's `backup.volume` are archived. Leave caches and rebuildable data (CI runner state, build caches) out of that list.
94+- Stored size is roughly `retention.keep` × the compressed size of each backed-up volume.
8995 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
9096 - Automated volume restore is not part of the MVP.
9197 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
@@ -50,6 +50,7 @@ prefix = "prod/gordon"
50 # path_style = false50 # path_style = false
51 # sse_algorithm = "AES256"51 # sse_algorithm = "AES256"
52 # sse_kms_key_id = ""52 # sse_kms_key_id = ""
53+# storage_class = "ONEZONE_IA"
53 54
54 [backups.volumes.retention]55 [backups.volumes.retention]
55 keep = 1456 keep = 14
@@ -68,6 +69,9 @@ keep = 14
68 | `backups.volumes.s3.prefix` | `""` | Object key prefix |69 | `backups.volumes.s3.prefix` | `""` | Object key prefix |
69 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |70 | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint |
70 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |71 | `backups.volumes.s3.path_style` | `false` | Use path-style addressing for S3-compatible storage |
72+| `backups.volumes.s3.sse_algorithm` | `""` | Server-side encryption sent with each upload, such as `AES256` |
73+| `backups.volumes.s3.sse_kms_key_id` | `""` | KMS key ID for `aws:kms` encryption |
74+| `backups.volumes.s3.storage_class` | `""` | Storage class sent with each upload, such as `STANDARD_IA` (AWS) or `ONEZONE_IA` (Scaleway One Zone); empty uses the bucket default |
71 | `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |75 | `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume |
72 76
73 Volume backup objects are stored under the canonical app name:77 Volume backup objects are stored under the canonical app name:
@@ -86,6 +90,8 @@ the current layout.
86 - Volume backups archive the app's declared volumes that its backup declaration references.90 - Volume backups archive the app's declared volumes that its backup declaration references.
87 - Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.91 - Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up.
88 - Database backups archive the app's declared PostgreSQL databases that its backup declaration references.92 - Database backups archive the app's declared PostgreSQL databases that its backup declaration references.
93+- Only volumes listed in a service's `backup.volume` are archived. Leave caches and rebuildable data (CI runner state, build caches) out of that list.
94+- Stored size is roughly `retention.keep` × the compressed size of each backed-up volume.
89 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.95 - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop.
90 - Automated volume restore is not part of the MVP.96 - Automated volume restore is not part of the MVP.
91 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.97 - Snapshots are not generic across Docker/Podman volumes and require backend-specific support such as ZFS, Btrfs, LVM, EBS, or a snapshot-capable volume driver.
M internal/adapters/out/s3/volume_backup_storage.go +5 -0
@@ -40,6 +40,7 @@ type VolumeBackupStorage struct {
4040 prefix string
4141 sseAlgorithm string
4242 sseKMSKeyID string
43+ storageClass string
4344 client volumeS3Client
4445 uploader volumeUploader
4546 }
@@ -74,6 +75,7 @@ func NewVolumeBackupStorageWithClients(cfg domain.VolumeBackupConfig, client vol
7475 prefix: normalizeS3Prefix(cfg.S3Prefix),
7576 sseAlgorithm: strings.TrimSpace(cfg.S3SSEAlgorithm),
7677 sseKMSKeyID: strings.TrimSpace(cfg.S3SSEKMSKeyID),
78+ storageClass: strings.TrimSpace(cfg.S3StorageClass),
7779 client: client,
7880 uploader: uploader,
7981 }
@@ -119,6 +121,9 @@ func (s *VolumeBackupStorage) StoreVolumeArchive(ctx context.Context, job domain
119121 if s.sseKMSKeyID != "" {
120122 input.SSEKMSKeyID = aws.String(s.sseKMSKeyID)
121123 }
124+ if s.storageClass != "" {
125+ input.StorageClass = tmtypes.StorageClass(s.storageClass)
126+ }
122127
123128 if _, err := s.uploader.UploadObject(ctx, input); err != nil {
124129 return "", fmt.Errorf("failed to upload volume archive: %w", err)
@@ -40,6 +40,7 @@ type VolumeBackupStorage struct {
40 prefix string40 prefix string
41 sseAlgorithm string41 sseAlgorithm string
42 sseKMSKeyID string42 sseKMSKeyID string
43+ storageClass string
43 client volumeS3Client44 client volumeS3Client
44 uploader volumeUploader45 uploader volumeUploader
45 }46 }
@@ -74,6 +75,7 @@ func NewVolumeBackupStorageWithClients(cfg domain.VolumeBackupConfig, client vol
74 prefix: normalizeS3Prefix(cfg.S3Prefix),75 prefix: normalizeS3Prefix(cfg.S3Prefix),
75 sseAlgorithm: strings.TrimSpace(cfg.S3SSEAlgorithm),76 sseAlgorithm: strings.TrimSpace(cfg.S3SSEAlgorithm),
76 sseKMSKeyID: strings.TrimSpace(cfg.S3SSEKMSKeyID),77 sseKMSKeyID: strings.TrimSpace(cfg.S3SSEKMSKeyID),
78+ storageClass: strings.TrimSpace(cfg.S3StorageClass),
77 client: client,79 client: client,
78 uploader: uploader,80 uploader: uploader,
79 }81 }
@@ -119,6 +121,9 @@ func (s *VolumeBackupStorage) StoreVolumeArchive(ctx context.Context, job domain
119 if s.sseKMSKeyID != "" {121 if s.sseKMSKeyID != "" {
120 input.SSEKMSKeyID = aws.String(s.sseKMSKeyID)122 input.SSEKMSKeyID = aws.String(s.sseKMSKeyID)
121 }123 }
124+ if s.storageClass != "" {
125+ input.StorageClass = tmtypes.StorageClass(s.storageClass)
126+ }
122 127
123 if _, err := s.uploader.UploadObject(ctx, input); err != nil {128 if _, err := s.uploader.UploadObject(ctx, input); err != nil {
124 return "", fmt.Errorf("failed to upload volume archive: %w", err)129 return "", fmt.Errorf("failed to upload volume archive: %w", err)
M internal/adapters/out/s3/volume_backup_storage_test.go +20 -0
@@ -101,6 +101,7 @@ func TestVolumeBackupStorageStoreListGet(t *testing.T) {
101101
102102 assert.Equal(t, "s3://gordon-backups/prod/gordon/apps/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)
103103 assert.Equal(t, "application/zstd", aws.ToString(uploader.last.ContentType))
104+ assert.Empty(t, uploader.last.StorageClass, "no storage class keeps the bucket default")
104105
105106 jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
106107 require.NoError(t, err)
@@ -121,6 +122,25 @@ func TestVolumeBackupStorageStoreListGet(t *testing.T) {
121122 assert.Equal(t, []byte("archive"), data)
122123 }
123124
125+func 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+
124144 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
125145 client := newFakeVolumeS3Client()
126146 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
@@ -101,6 +101,7 @@ func TestVolumeBackupStorageStoreListGet(t *testing.T) {
101 101
102 assert.Equal(t, "s3://gordon-backups/prod/gordon/apps/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact)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))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")
104 105
105 jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")106 jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com")
106 require.NoError(t, err)107 require.NoError(t, err)
@@ -121,6 +122,25 @@ func TestVolumeBackupStorageStoreListGet(t *testing.T) {
121 assert.Equal(t, []byte("archive"), data)122 assert.Equal(t, []byte("archive"), data)
122 }123 }
123 124
125+func 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+
124 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {144 func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) {
125 client := newFakeVolumeS3Client()145 client := newFakeVolumeS3Client()
126 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})146 storage := NewVolumeBackupStorageWithClients(domain.VolumeBackupConfig{S3Bucket: "bucket"}, client, &fakeVolumeUploader{client: client})
M internal/app/backup_wiring.go +18 -0
@@ -187,6 +187,10 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
187187 if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
188188 return domain.VolumeBackupConfig{}, err
189189 }
190+ storageClass, err := parseS3StorageClass(volumeCfg.S3.StorageClass)
191+ if err != nil {
192+ return domain.VolumeBackupConfig{}, err
193+ }
190194 return domain.VolumeBackupConfig{
191195 Enabled: volumeCfg.Enabled,
192196 Interval: interval,
@@ -203,9 +207,23 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
203207 S3PathStyle: volumeCfg.S3.PathStyle,
204208 S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
205209 S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
210+ S3StorageClass: storageClass,
206211 }, nil
207212 }
208213
214+// parseS3StorageClass normalizes a storage class name. Providers define
215+// their own classes (AWS GLACIER_IR, Scaleway ONEZONE_IA, ...), so any
216+// S3-style token is accepted and the provider validates it on upload.
217+func parseS3StorageClass(raw string) (string, error) {
218+ class := strings.ToUpper(strings.TrimSpace(raw))
219+ for _, r := range class {
220+ if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
221+ return "", fmt.Errorf("backups.volumes.s3.storage_class must contain only letters, digits, and underscores")
222+ }
223+ }
224+ return class, nil
225+}
226+
209227 func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
210228 if strings.TrimSpace(raw) == "" {
211229 raw = defaultValue
@@ -187,6 +187,10 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
187 if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {187 if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
188 return domain.VolumeBackupConfig{}, err188 return domain.VolumeBackupConfig{}, err
189 }189 }
190+ storageClass, err := parseS3StorageClass(volumeCfg.S3.StorageClass)
191+ if err != nil {
192+ return domain.VolumeBackupConfig{}, err
193+ }
190 return domain.VolumeBackupConfig{194 return domain.VolumeBackupConfig{
191 Enabled: volumeCfg.Enabled,195 Enabled: volumeCfg.Enabled,
192 Interval: interval,196 Interval: interval,
@@ -203,9 +207,23 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
203 S3PathStyle: volumeCfg.S3.PathStyle,207 S3PathStyle: volumeCfg.S3.PathStyle,
204 S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),208 S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
205 S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),209 S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
210+ S3StorageClass: storageClass,
206 }, nil211 }, nil
207 }212 }
208 213
214+// parseS3StorageClass normalizes a storage class name. Providers define
215+// their own classes (AWS GLACIER_IR, Scaleway ONEZONE_IA, ...), so any
216+// S3-style token is accepted and the provider validates it on upload.
217+func parseS3StorageClass(raw string) (string, error) {
218+ class := strings.ToUpper(strings.TrimSpace(raw))
219+ for _, r := range class {
220+ if (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' {
221+ return "", fmt.Errorf("backups.volumes.s3.storage_class must contain only letters, digits, and underscores")
222+ }
223+ }
224+ return class, nil
225+}
226+
209 func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {227 func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
210 if strings.TrimSpace(raw) == "" {228 if strings.TrimSpace(raw) == "" {
211 raw = defaultValue229 raw = defaultValue
M internal/app/config.go +2 -0
@@ -161,6 +161,7 @@ type Config struct {
161161 PathStyle bool `mapstructure:"path_style"`
162162 SSEAlgorithm string `mapstructure:"sse_algorithm"`
163163 SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
164+ StorageClass string `mapstructure:"storage_class"`
164165 } `mapstructure:"s3"`
165166 Retention struct {
166167 Keep int `mapstructure:"keep"`
@@ -431,6 +432,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
431432 v.SetDefault("backups.volumes.s3.path_style", false)
432433 v.SetDefault("backups.volumes.s3.sse_algorithm", "")
433434 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
435+ v.SetDefault("backups.volumes.s3.storage_class", "")
434436 v.SetDefault("backups.volumes.retention.keep", 14)
435437 v.SetDefault("images.allowed_registries", []string{})
436438 v.SetDefault("images.require_digest", false)
@@ -161,6 +161,7 @@ type Config struct {
161 PathStyle bool `mapstructure:"path_style"`161 PathStyle bool `mapstructure:"path_style"`
162 SSEAlgorithm string `mapstructure:"sse_algorithm"`162 SSEAlgorithm string `mapstructure:"sse_algorithm"`
163 SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`163 SSEKMSKeyID string `mapstructure:"sse_kms_key_id"`
164+ StorageClass string `mapstructure:"storage_class"`
164 } `mapstructure:"s3"`165 } `mapstructure:"s3"`
165 Retention struct {166 Retention struct {
166 Keep int `mapstructure:"keep"`167 Keep int `mapstructure:"keep"`
@@ -431,6 +432,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
431 v.SetDefault("backups.volumes.s3.path_style", false)432 v.SetDefault("backups.volumes.s3.path_style", false)
432 v.SetDefault("backups.volumes.s3.sse_algorithm", "")433 v.SetDefault("backups.volumes.s3.sse_algorithm", "")
433 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")434 v.SetDefault("backups.volumes.s3.sse_kms_key_id", "")
435+ v.SetDefault("backups.volumes.s3.storage_class", "")
434 v.SetDefault("backups.volumes.retention.keep", 14)436 v.SetDefault("backups.volumes.retention.keep", 14)
435 v.SetDefault("images.allowed_registries", []string{})437 v.SetDefault("images.allowed_registries", []string{})
436 v.SetDefault("images.require_digest", false)438 v.SetDefault("images.require_digest", false)
M internal/app/run_backup_test.go +26 -0
@@ -165,6 +165,32 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
165165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
166166 })
167167
168+ t.Run("storage class is normalized and passed through", func(t *testing.T) {
169+ var cfg Config
170+ cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "
171+
172+ volumeCfg, err := validateVolumeBackupConfig(cfg)
173+ require.NoError(t, err)
174+ assert.Equal(t, "ONEZONE_IA", volumeCfg.S3StorageClass)
175+ })
176+
177+ t.Run("empty storage class keeps the bucket default", func(t *testing.T) {
178+ var cfg Config
179+
180+ volumeCfg, err := validateVolumeBackupConfig(cfg)
181+ require.NoError(t, err)
182+ assert.Empty(t, volumeCfg.S3StorageClass)
183+ })
184+
185+ t.Run("storage class rejects unsafe values", func(t *testing.T) {
186+ var cfg Config
187+ cfg.Backups.Volumes.S3.StorageClass = "one zone"
188+
189+ _, err := validateVolumeBackupConfig(cfg)
190+ require.Error(t, err)
191+ assert.Contains(t, err.Error(), "backups.volumes.s3.storage_class")
192+ })
193+
168194 t.Run("zstd requires helper image with zstd", func(t *testing.T) {
169195 var cfg Config
170196 cfg.Backups.Volumes.Compression = "zstd"
@@ -165,6 +165,32 @@ func TestValidateVolumeBackupConfig(t *testing.T) {
165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)165 assert.Equal(t, "prod/gordon", volumeCfg.S3Prefix)
166 })166 })
167 167
168+ t.Run("storage class is normalized and passed through", func(t *testing.T) {
169+ var cfg Config
170+ cfg.Backups.Volumes.S3.StorageClass = " onezone_ia "
171+
172+ volumeCfg, err := validateVolumeBackupConfig(cfg)
173+ require.NoError(t, err)
174+ assert.Equal(t, "ONEZONE_IA", volumeCfg.S3StorageClass)
175+ })
176+
177+ t.Run("empty storage class keeps the bucket default", func(t *testing.T) {
178+ var cfg Config
179+
180+ volumeCfg, err := validateVolumeBackupConfig(cfg)
181+ require.NoError(t, err)
182+ assert.Empty(t, volumeCfg.S3StorageClass)
183+ })
184+
185+ t.Run("storage class rejects unsafe values", func(t *testing.T) {
186+ var cfg Config
187+ cfg.Backups.Volumes.S3.StorageClass = "one zone"
188+
189+ _, err := validateVolumeBackupConfig(cfg)
190+ require.Error(t, err)
191+ assert.Contains(t, err.Error(), "backups.volumes.s3.storage_class")
192+ })
193+
168 t.Run("zstd requires helper image with zstd", func(t *testing.T) {194 t.Run("zstd requires helper image with zstd", func(t *testing.T) {
169 var cfg Config195 var cfg Config
170 cfg.Backups.Volumes.Compression = "zstd"196 cfg.Backups.Volumes.Compression = "zstd"
M internal/domain/backup.go +2 -0
@@ -141,6 +141,8 @@ type VolumeBackupConfig struct {
141141 S3PathStyle bool
142142 S3SSEAlgorithm string
143143 S3SSEKMSKeyID string
144+ // S3StorageClass is sent with every upload; empty keeps the bucket default.
145+ S3StorageClass string
144146 }
145147
146148 // VolumeBackupJob represents a filesystem archive backup of a named volume.
@@ -141,6 +141,8 @@ type VolumeBackupConfig struct {
141 S3PathStyle bool141 S3PathStyle bool
142 S3SSEAlgorithm string142 S3SSEAlgorithm string
143 S3SSEKMSKeyID string143 S3SSEKMSKeyID string
144+ // S3StorageClass is sent with every upload; empty keeps the bucket default.
145+ S3StorageClass string
144 }146 }
145 147
146 // VolumeBackupJob represents a filesystem archive backup of a named volume.148 // VolumeBackupJob represents a filesystem archive backup of a named volume.

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