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