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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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" | |||
| 50 | 50 | # path_style = false | |
| 51 | 51 | # sse_algorithm = "AES256" | |
| 52 | 52 | # sse_kms_key_id = "" | |
| 53 | + | # storage_class = "ONEZONE_IA" | |
| 53 | 54 | ||
| 54 | 55 | [backups.volumes.retention] | |
| 55 | 56 | keep = 14 | |
| @@ -68,6 +69,9 @@ keep = 14 | |||
| 68 | 69 | | `backups.volumes.s3.prefix` | `""` | Object key prefix | | |
| 69 | 70 | | `backups.volumes.s3.endpoint` | `""` | Optional S3-compatible endpoint | | |
| 70 | 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 | 75 | | `backups.volumes.retention.keep` | `14` | Completed archives to keep per app + volume | | |
| 72 | 76 | ||
| 73 | 77 | Volume backup objects are stored under the canonical app name: | |
| @@ -86,6 +90,8 @@ the current layout. | |||
| 86 | 90 | - Volume backups archive the app's declared volumes that its backup declaration references. | |
| 87 | 91 | - Bind mounts, tmpfs mounts, anonymous volumes, and undeclared volumes are never backed up. | |
| 88 | 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 | 95 | - Live volume archives are best-effort; consistency requires application quiesce, pause, or stop. | |
| 90 | 96 | - Automated volume restore is not part of the MVP. | |
| 91 | 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. | |
| @@ -50,6 +50,7 @@ prefix = "prod/gordon" | |||||
| 50 | # path_style = false | 50 | # 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 = 14 | 56 | 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 { | |||
| 40 | 40 | prefix string | |
| 41 | 41 | sseAlgorithm string | |
| 42 | 42 | sseKMSKeyID string | |
| 43 | + | storageClass string | |
| 43 | 44 | client volumeS3Client | |
| 44 | 45 | uploader volumeUploader | |
| 45 | 46 | } | |
| @@ -74,6 +75,7 @@ func NewVolumeBackupStorageWithClients(cfg domain.VolumeBackupConfig, client vol | |||
| 74 | 75 | prefix: normalizeS3Prefix(cfg.S3Prefix), | |
| 75 | 76 | sseAlgorithm: strings.TrimSpace(cfg.S3SSEAlgorithm), | |
| 76 | 77 | sseKMSKeyID: strings.TrimSpace(cfg.S3SSEKMSKeyID), | |
| 78 | + | storageClass: strings.TrimSpace(cfg.S3StorageClass), | |
| 77 | 79 | client: client, | |
| 78 | 80 | uploader: uploader, | |
| 79 | 81 | } | |
| @@ -119,6 +121,9 @@ func (s *VolumeBackupStorage) StoreVolumeArchive(ctx context.Context, job domain | |||
| 119 | 121 | if s.sseKMSKeyID != "" { | |
| 120 | 122 | input.SSEKMSKeyID = aws.String(s.sseKMSKeyID) | |
| 121 | 123 | } | |
| 124 | + | if s.storageClass != "" { | |
| 125 | + | input.StorageClass = tmtypes.StorageClass(s.storageClass) | |
| 126 | + | } | |
| 122 | 127 | ||
| 123 | 128 | if _, err := s.uploader.UploadObject(ctx, input); err != nil { | |
| 124 | 129 | return "", fmt.Errorf("failed to upload volume archive: %w", err) | |
| @@ -40,6 +40,7 @@ type VolumeBackupStorage struct { | |||||
| 40 | prefix string | 40 | prefix string | ||
| 41 | sseAlgorithm string | 41 | sseAlgorithm string | ||
| 42 | sseKMSKeyID string | 42 | sseKMSKeyID string | ||
| 43 | + | storageClass string | |||
| 43 | client volumeS3Client | 44 | client volumeS3Client | ||
| 44 | uploader volumeUploader | 45 | 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) { | |||
| 101 | 101 | ||
| 102 | 102 | assert.Equal(t, "s3://gordon-backups/prod/gordon/apps/app.example.com/volumes/gordon-app-data/20260619T020000Z-job_1.tar.zst", artifact) | |
| 103 | 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 | 106 | jobs, err := storage.ListVolumeArchives(context.Background(), "app.example.com") | |
| 106 | 107 | require.NoError(t, err) | |
| @@ -121,6 +122,25 @@ func TestVolumeBackupStorageStoreListGet(t *testing.T) { | |||
| 121 | 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 | 144 | func TestVolumeBackupStorageRetentionUsesParsedKeyTimestamp(t *testing.T) { | |
| 125 | 145 | client := newFakeVolumeS3Client() | |
| 126 | 146 | 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) { | |||
| 187 | 187 | if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil { | |
| 188 | 188 | return domain.VolumeBackupConfig{}, err | |
| 189 | 189 | } | |
| 190 | + | storageClass, err := parseS3StorageClass(volumeCfg.S3.StorageClass) | |
| 191 | + | if err != nil { | |
| 192 | + | return domain.VolumeBackupConfig{}, err | |
| 193 | + | } | |
| 190 | 194 | return domain.VolumeBackupConfig{ | |
| 191 | 195 | Enabled: volumeCfg.Enabled, | |
| 192 | 196 | Interval: interval, | |
| @@ -203,9 +207,23 @@ func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) { | |||
| 203 | 207 | S3PathStyle: volumeCfg.S3.PathStyle, | |
| 204 | 208 | S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm), | |
| 205 | 209 | S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID), | |
| 210 | + | S3StorageClass: storageClass, | |
| 206 | 211 | }, 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 | 227 | func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) { | |
| 210 | 228 | if strings.TrimSpace(raw) == "" { | |
| 211 | 229 | 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{}, err | 188 | 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 | }, nil | 211 | }, 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 = defaultValue | 229 | raw = defaultValue | ||
M internal/app/config.go +2 -0
| @@ -161,6 +161,7 @@ type Config struct { | |||
| 161 | 161 | PathStyle bool `mapstructure:"path_style"` | |
| 162 | 162 | SSEAlgorithm string `mapstructure:"sse_algorithm"` | |
| 163 | 163 | SSEKMSKeyID string `mapstructure:"sse_kms_key_id"` | |
| 164 | + | StorageClass string `mapstructure:"storage_class"` | |
| 164 | 165 | } `mapstructure:"s3"` | |
| 165 | 166 | Retention struct { | |
| 166 | 167 | Keep int `mapstructure:"keep"` | |
| @@ -431,6 +432,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 431 | 432 | v.SetDefault("backups.volumes.s3.path_style", false) | |
| 432 | 433 | v.SetDefault("backups.volumes.s3.sse_algorithm", "") | |
| 433 | 434 | v.SetDefault("backups.volumes.s3.sse_kms_key_id", "") | |
| 435 | + | v.SetDefault("backups.volumes.s3.storage_class", "") | |
| 434 | 436 | v.SetDefault("backups.volumes.retention.keep", 14) | |
| 435 | 437 | v.SetDefault("images.allowed_registries", []string{}) | |
| 436 | 438 | 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) { | |||
| 165 | 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 | 194 | t.Run("zstd requires helper image with zstd", func(t *testing.T) { | |
| 169 | 195 | var cfg Config | |
| 170 | 196 | 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 Config | 195 | 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 { | |||
| 141 | 141 | S3PathStyle bool | |
| 142 | 142 | S3SSEAlgorithm string | |
| 143 | 143 | S3SSEKMSKeyID string | |
| 144 | + | // S3StorageClass is sent with every upload; empty keeps the bucket default. | |
| 145 | + | S3StorageClass string | |
| 144 | 146 | } | |
| 145 | 147 | ||
| 146 | 148 | // VolumeBackupJob represents a filesystem archive backup of a named volume. | |
| @@ -141,6 +141,8 @@ type VolumeBackupConfig struct { | |||||
| 141 | S3PathStyle bool | 141 | S3PathStyle bool | ||
| 142 | S3SSEAlgorithm string | 142 | S3SSEAlgorithm string | ||
| 143 | S3SSEKMSKeyID string | 143 | 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. | ||