1package app
2
3import (
4 "context"
5 "fmt"
6 "path/filepath"
7 "strings"
8 "time"
9
10 "github.com/bnema/zerowrap"
11
12 "github.com/bnema/gordon/internal/adapters/out/filesystem"
13 s3storage "github.com/bnema/gordon/internal/adapters/out/s3"
14 "github.com/bnema/gordon/internal/boundaries/out"
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/backup"
17)
18
19type databaseBackupSettingsConfig struct {
20 Enabled bool
21 Schedule string
22 StorageDir string
23 Retention struct {
24 Hourly int
25 Daily int
26 Weekly int
27 Monthly int
28 }
29}
30
31func databaseBackupSettings(cfg Config) databaseBackupSettingsConfig {
32 out := databaseBackupSettingsConfig{
33 Enabled: cfg.Backups.Databases.Enabled,
34 Schedule: cfg.Backups.Databases.Schedule,
35 StorageDir: cfg.Backups.Databases.StorageDir,
36 }
37 out.Retention.Hourly = cfg.Backups.Databases.Retention.Hourly
38 out.Retention.Daily = cfg.Backups.Databases.Retention.Daily
39 out.Retention.Weekly = cfg.Backups.Databases.Retention.Weekly
40 out.Retention.Monthly = cfg.Backups.Databases.Retention.Monthly
41 // Legacy [backups] keys intentionally override new database defaults when
42 // backups.enabled is true, preserving existing working pg_dump schedules.
43 // Otherwise, prefer the already-populated backups.databases.* values.
44 if cfg.Backups.Enabled {
45 out.Enabled = true
46 if cfg.Backups.Schedule != "" {
47 out.Schedule = cfg.Backups.Schedule
48 }
49 if cfg.Backups.StorageDir != "" {
50 out.StorageDir = cfg.Backups.StorageDir
51 }
52 } else {
53 if out.Schedule == "" {
54 out.Schedule = cfg.Backups.Schedule
55 }
56 if out.StorageDir == "" {
57 out.StorageDir = cfg.Backups.StorageDir
58 }
59 }
60 if out.Retention.Hourly == 0 {
61 out.Retention.Hourly = cfg.Backups.Retention.Hourly
62 }
63 if out.Retention.Daily == 0 {
64 out.Retention.Daily = cfg.Backups.Retention.Daily
65 }
66 if out.Retention.Weekly == 0 {
67 out.Retention.Weekly = cfg.Backups.Retention.Weekly
68 }
69 if out.Retention.Monthly == 0 {
70 out.Retention.Monthly = cfg.Backups.Retention.Monthly
71 }
72 return out
73}
74
75func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
76 dbCfg := databaseBackupSettings(cfg)
77 if !dbCfg.Enabled {
78 return nil, nil, nil
79 }
80
81 storageDir := dbCfg.StorageDir
82 if storageDir == "" {
83 dataDir := resolveDataDir(cfg.Server.DataDir)
84 storageDir = filepath.Join(dataDir, "backups")
85 }
86
87 backupStorage, err := filesystem.NewBackupStorage(storageDir, log)
88 if err != nil {
89 return nil, nil, log.WrapErr(err, "failed to create backup storage")
90 }
91
92 retention, err := validateBackupRetention(cfg)
93 if err != nil {
94 return nil, nil, log.WrapErr(err, "invalid backup retention policy")
95 }
96
97 backupCfg := domain.BackupConfig{
98 Enabled: dbCfg.Enabled,
99 StorageDir: storageDir,
100 Retention: retention,
101 }
102
103 backupSvc := backup.NewService(svc.runtime, backupStorage, backupCfg, log)
104
105 log.Info().
106 Str("storage_dir", storageDir).
107 Msg("backup service initialized")
108
109 return backupStorage, backupSvc, nil
110}
111
112func createVolumeBackupService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (out.VolumeBackupStorage, *backup.VolumeService, domain.VolumeBackupConfig, error) {
113 if !cfg.Backups.Volumes.Enabled {
114 return nil, nil, domain.VolumeBackupConfig{}, nil
115 }
116 volumeCfg, err := validateVolumeBackupConfig(cfg)
117 if err != nil {
118 return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "invalid volume backup configuration")
119 }
120
121 storage, err := s3storage.NewVolumeBackupStorage(ctx, volumeCfg)
122 if err != nil {
123 return nil, nil, domain.VolumeBackupConfig{}, log.WrapErr(err, "failed to create volume backup storage")
124 }
125
126 volumeSvc := backup.NewVolumeService(svc.runtime, storage, volumeCfg, log)
127 log.Info().
128 Str("bucket", volumeCfg.S3Bucket).
129 Str("prefix", volumeCfg.S3Prefix).
130 Msg("volume backup service initialized")
131
132 return storage, volumeSvc, volumeCfg, nil
133}
134
135func validateBackupRetention(cfg Config) (domain.RetentionPolicy, error) {
136 dbCfg := databaseBackupSettings(cfg)
137 if dbCfg.Retention.Hourly < 0 {
138 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.hourly cannot be negative")
139 }
140 if dbCfg.Retention.Daily < 0 {
141 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.daily cannot be negative")
142 }
143 if dbCfg.Retention.Weekly < 0 {
144 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.weekly cannot be negative")
145 }
146 if dbCfg.Retention.Monthly < 0 {
147 return domain.RetentionPolicy{}, fmt.Errorf("backups.databases.retention.monthly cannot be negative")
148 }
149
150 return domain.RetentionPolicy{
151 Hourly: dbCfg.Retention.Hourly,
152 Daily: dbCfg.Retention.Daily,
153 Weekly: dbCfg.Retention.Weekly,
154 Monthly: dbCfg.Retention.Monthly,
155 }, nil
156}
157
158func validateVolumeBackupConfig(cfg Config) (domain.VolumeBackupConfig, error) {
159 volumeCfg := cfg.Backups.Volumes
160 interval, err := parsePositiveDurationDefault(volumeCfg.Interval, "24h", "backups.volumes.interval")
161 if err != nil {
162 return domain.VolumeBackupConfig{}, err
163 }
164 timeout, err := parsePositiveDurationDefault(volumeCfg.Timeout, "2h", "backups.volumes.timeout")
165 if err != nil {
166 return domain.VolumeBackupConfig{}, err
167 }
168 compression, err := parseVolumeBackupCompression(volumeCfg.Compression)
169 if err != nil {
170 return domain.VolumeBackupConfig{}, err
171 }
172 maxConcurrency := volumeCfg.MaxConcurrency
173 if maxConcurrency == 0 {
174 maxConcurrency = 2
175 }
176 helperImage := strings.TrimSpace(volumeCfg.HelperImage)
177 if helperImage == "" {
178 helperImage = "alpine:3.20"
179 }
180 volumePrefix := strings.TrimSpace(cfg.Volumes.Prefix)
181 if volumePrefix == "" {
182 volumePrefix = "gordon"
183 }
184 if compression == domain.VolumeBackupCompressionZstd && helperImage == "alpine:3.20" {
185 return domain.VolumeBackupConfig{}, fmt.Errorf("backups.volumes.compression zstd requires a helper_image that provides zstd")
186 }
187 if err := validateVolumeBackupS3Settings(volumeCfg.Enabled, volumeCfg.Retention.Keep, maxConcurrency, volumeCfg.S3.Bucket, volumeCfg.S3.Region); err != nil {
188 return domain.VolumeBackupConfig{}, err
189 }
190 storageClass, err := parseS3StorageClass(volumeCfg.S3.StorageClass)
191 if err != nil {
192 return domain.VolumeBackupConfig{}, err
193 }
194 return domain.VolumeBackupConfig{
195 Enabled: volumeCfg.Enabled,
196 Interval: interval,
197 Compression: compression,
198 Retention: domain.VolumeBackupRetentionPolicy{Keep: volumeCfg.Retention.Keep},
199 Timeout: timeout,
200 MaxConcurrency: maxConcurrency,
201 HelperImage: helperImage,
202 VolumePrefix: volumePrefix,
203 S3Bucket: strings.TrimSpace(volumeCfg.S3.Bucket),
204 S3Region: strings.TrimSpace(volumeCfg.S3.Region),
205 S3Prefix: strings.TrimSpace(volumeCfg.S3.Prefix),
206 S3Endpoint: strings.TrimSpace(volumeCfg.S3.Endpoint),
207 S3PathStyle: volumeCfg.S3.PathStyle,
208 S3SSEAlgorithm: strings.TrimSpace(volumeCfg.S3.SSEAlgorithm),
209 S3SSEKMSKeyID: strings.TrimSpace(volumeCfg.S3.SSEKMSKeyID),
210 S3StorageClass: storageClass,
211 }, nil
212}
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.
217func 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
227func parsePositiveDurationDefault(raw, defaultValue, field string) (time.Duration, error) {
228 if strings.TrimSpace(raw) == "" {
229 raw = defaultValue
230 }
231 d, err := time.ParseDuration(strings.TrimSpace(raw))
232 if err != nil || d <= 0 {
233 return 0, fmt.Errorf("%s must be a positive duration", field)
234 }
235 return d, nil
236}
237
238func parseVolumeBackupCompression(raw string) (domain.VolumeBackupCompression, error) {
239 if strings.TrimSpace(raw) == "" {
240 raw = string(domain.VolumeBackupCompressionGzip)
241 }
242 compression := domain.VolumeBackupCompression(strings.ToLower(strings.TrimSpace(raw)))
243 switch compression {
244 case domain.VolumeBackupCompressionGzip, domain.VolumeBackupCompressionZstd:
245 return compression, nil
246 default:
247 return "", fmt.Errorf("backups.volumes.compression must be one of: gzip, zstd")
248 }
249}
250
251func validateVolumeBackupS3Settings(enabled bool, keep, maxConcurrency int, bucket, region string) error {
252 if keep < 0 {
253 return fmt.Errorf("backups.volumes.retention.keep cannot be negative")
254 }
255 if enabled && keep == 0 {
256 return fmt.Errorf("backups.volumes.retention.keep must be positive when volume backups are enabled")
257 }
258 if maxConcurrency < 1 {
259 return fmt.Errorf("backups.volumes.max_concurrency must be at least 1")
260 }
261 if enabled && strings.TrimSpace(bucket) == "" {
262 return fmt.Errorf("backups.volumes.s3.bucket is required when volume backups are enabled")
263 }
264 if enabled && strings.TrimSpace(region) == "" {
265 return fmt.Errorf("backups.volumes.s3.region is required when volume backups are enabled")
266 }
267 return nil
268}