feat(backup): wire scheduled backup execution and retention
- commit
- f6bf3a976ee3d852196e93d34074f106f0fcc0c4
- author
- brice · · 2026-02-08 09:50 UTC
- parent
- df21944
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +215 -1
M docs/config/backups.md +3 -0
| @@ -21,6 +21,7 @@ Current scope: | |||
| 21 | 21 | ```toml | |
| 22 | 22 | [backups] | |
| 23 | 23 | enabled = true | |
| 24 | + | schedule = "daily" | |
| 24 | 25 | storage_dir = "~/.gordon/backups" | |
| 25 | 26 | ||
| 26 | 27 | [backups.retention] | |
| @@ -35,6 +36,7 @@ monthly = 12 | |||
| 35 | 36 | | Key | Type | Default | Description | | |
| 36 | 37 | |-----|------|---------|-------------| | |
| 37 | 38 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | |
| 39 | + | | `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` | | |
| 38 | 40 | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | |
| 39 | 41 | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | |
| 40 | 42 | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | |
| @@ -58,6 +60,7 @@ Example: | |||
| 58 | 60 | ## Notes | |
| 59 | 61 | ||
| 60 | 62 | - Backups require PostgreSQL attachment containers to be running. | |
| 63 | + | - Scheduled backups run only when `backups.enabled = true`. | |
| 61 | 64 | - The backup command executes inside the database container using Gordon runtime APIs. | |
| 62 | 65 | - Retention applies per database and per schedule. | |
| 63 | 66 | - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`. | |
| @@ -21,6 +21,7 @@ Current scope: | |||||
| 21 | ```toml | 21 | ```toml | ||
| 22 | [backups] | 22 | [backups] | ||
| 23 | enabled = true | 23 | enabled = true | ||
| 24 | + | schedule = "daily" | |||
| 24 | storage_dir = "~/.gordon/backups" | 25 | storage_dir = "~/.gordon/backups" | ||
| 25 | 26 | ||||
| 26 | [backups.retention] | 27 | [backups.retention] | ||
| @@ -35,6 +36,7 @@ monthly = 12 | |||||
| 35 | | Key | Type | Default | Description | | 36 | | Key | Type | Default | Description | | ||
| 36 | |-----|------|---------|-------------| | 37 | |-----|------|---------|-------------| | ||
| 37 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | 38 | | `backups.enabled` | bool | `false` | Enables backup service wiring | | ||
| 39 | + | | `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` | | |||
| 38 | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | 40 | | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` | | ||
| 39 | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | 41 | | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB | | ||
| 40 | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | 42 | | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB | | ||
| @@ -58,6 +60,7 @@ Example: | |||||
| 58 | ## Notes | 60 | ## Notes | ||
| 59 | 61 | ||||
| 60 | - Backups require PostgreSQL attachment containers to be running. | 62 | - Backups require PostgreSQL attachment containers to be running. | ||
| 63 | + | - Scheduled backups run only when `backups.enabled = true`. | |||
| 61 | - The backup command executes inside the database container using Gordon runtime APIs. | 64 | - The backup command executes inside the database container using Gordon runtime APIs. | ||
| 62 | - Retention applies per database and per schedule. | 65 | - Retention applies per database and per schedule. | ||
| 63 | - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`. | 66 | - `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`. | ||
M docs/config/index.md +2 -0
| @@ -119,6 +119,7 @@ enabled = false # Auto-create routes from image names | |||
| 119 | 119 | # Backups | |
| 120 | 120 | [backups] | |
| 121 | 121 | enabled = true | |
| 122 | + | schedule = "daily" | |
| 122 | 123 | storage_dir = "~/.gordon/backups" | |
| 123 | 124 | ||
| 124 | 125 | [backups.retention] | |
| @@ -189,6 +190,7 @@ keep_last = 3 | |||
| 189 | 190 | | `network_isolation.enabled` | `false` | | |
| 190 | 191 | | `auto_route.enabled` | `false` | | |
| 191 | 192 | | `backups.enabled` | `false` | | |
| 193 | + | | `backups.schedule` | `"daily"` | | |
| 192 | 194 | | `images.prune.enabled` | `false` | | |
| 193 | 195 | | `images.prune.schedule` | `"daily"` | | |
| 194 | 196 | | `images.prune.keep_last` | `3` | | |
| @@ -119,6 +119,7 @@ enabled = false # Auto-create routes from image names | |||||
| 119 | # Backups | 119 | # Backups | ||
| 120 | [backups] | 120 | [backups] | ||
| 121 | enabled = true | 121 | enabled = true | ||
| 122 | + | schedule = "daily" | |||
| 122 | storage_dir = "~/.gordon/backups" | 123 | storage_dir = "~/.gordon/backups" | ||
| 123 | 124 | ||||
| 124 | [backups.retention] | 125 | [backups.retention] | ||
| @@ -189,6 +190,7 @@ keep_last = 3 | |||||
| 189 | | `network_isolation.enabled` | `false` | | 190 | | `network_isolation.enabled` | `false` | | ||
| 190 | | `auto_route.enabled` | `false` | | 191 | | `auto_route.enabled` | `false` | | ||
| 191 | | `backups.enabled` | `false` | | 192 | | `backups.enabled` | `false` | | ||
| 193 | + | | `backups.schedule` | `"daily"` | | |||
| 192 | | `images.prune.enabled` | `false` | | 194 | | `images.prune.enabled` | `false` | | ||
| 193 | | `images.prune.schedule` | `"daily"` | | 195 | | `images.prune.schedule` | `"daily"` | | ||
| 194 | | `images.prune.keep_last` | `3` | | 196 | | `images.prune.keep_last` | `3` | | ||
M docs/config/reference.md +2 -0
| @@ -133,6 +133,7 @@ preserve = true # Keep volumes when containers are | |||
| 133 | 133 | # ============================================================================= | |
| 134 | 134 | [backups] | |
| 135 | 135 | enabled = false # Enable backup service | |
| 136 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |
| 136 | 137 | storage_dir = "" # Backup root (default: {data_dir}/backups) | |
| 137 | 138 | ||
| 138 | 139 | [backups.retention] | |
| @@ -200,6 +201,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 200 | 201 | | `volumes.prefix` | `"gordon"` | Volume prefix | | |
| 201 | 202 | | `volumes.preserve` | `true` | Keep volumes | | |
| 202 | 203 | | `backups.enabled` | `false` | Backup service disabled | | |
| 204 | + | | `backups.schedule` | `"daily"` | Backup scheduler preset | | |
| 203 | 205 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | |
| 204 | 206 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | |
| 205 | 207 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | |
| @@ -133,6 +133,7 @@ preserve = true # Keep volumes when containers are | |||||
| 133 | # ============================================================================= | 133 | # ============================================================================= | ||
| 134 | [backups] | 134 | [backups] | ||
| 135 | enabled = false # Enable backup service | 135 | enabled = false # Enable backup service | ||
| 136 | + | schedule = "daily" # "hourly", "daily", "weekly", "monthly" | |||
| 136 | storage_dir = "" # Backup root (default: {data_dir}/backups) | 137 | storage_dir = "" # Backup root (default: {data_dir}/backups) | ||
| 137 | 138 | ||||
| 138 | [backups.retention] | 139 | [backups.retention] | ||
| @@ -200,6 +201,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 200 | | `volumes.prefix` | `"gordon"` | Volume prefix | | 201 | | `volumes.prefix` | `"gordon"` | Volume prefix | | ||
| 201 | | `volumes.preserve` | `true` | Keep volumes | | 202 | | `volumes.preserve` | `true` | Keep volumes | | ||
| 202 | | `backups.enabled` | `false` | Backup service disabled | | 203 | | `backups.enabled` | `false` | Backup service disabled | | ||
| 204 | + | | `backups.schedule` | `"daily"` | Backup scheduler preset | | |||
| 203 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | 205 | | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty | | ||
| 204 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | 206 | | `backups.retention.hourly` | `0` | Keep no hourly backups by default | | ||
| 205 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | 207 | | `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) | | ||
M internal/app/run.go +61 -0
| @@ -135,6 +135,7 @@ type Config struct { | |||
| 135 | 135 | ||
| 136 | 136 | Backups struct { | |
| 137 | 137 | Enabled bool `mapstructure:"enabled"` | |
| 138 | + | Schedule string `mapstructure:"schedule"` | |
| 138 | 139 | StorageDir string `mapstructure:"storage_dir"` | |
| 139 | 140 | Retention struct { | |
| 140 | 141 | Hourly int `mapstructure:"hourly"` | |
| @@ -1446,6 +1447,14 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1446 | 1447 | } | |
| 1447 | 1448 | logEvent.Msg("Gordon is running") | |
| 1448 | 1449 | ||
| 1450 | + | backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log) | |
| 1451 | + | if err != nil { | |
| 1452 | + | return err | |
| 1453 | + | } | |
| 1454 | + | if backupScheduler != nil { | |
| 1455 | + | defer backupScheduler.Stop() | |
| 1456 | + | } | |
| 1457 | + | ||
| 1449 | 1458 | imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int { | |
| 1450 | 1459 | return v.GetInt("images.prune.keep_last") | |
| 1451 | 1460 | }) | |
| @@ -1464,6 +1473,57 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||
| 1464 | 1473 | return nil | |
| 1465 | 1474 | } | |
| 1466 | 1475 | ||
| 1476 | + | func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) { | |
| 1477 | + | if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil { | |
| 1478 | + | return nil, nil | |
| 1479 | + | } | |
| 1480 | + | ||
| 1481 | + | preset, err := resolveBackupSchedule(cfg.Backups.Schedule) | |
| 1482 | + | if err != nil { | |
| 1483 | + | return nil, err | |
| 1484 | + | } | |
| 1485 | + | ||
| 1486 | + | scheduler := cronSvc.NewScheduler(log) | |
| 1487 | + | err = scheduler.Add( | |
| 1488 | + | "backup-scheduler", | |
| 1489 | + | "Backups", | |
| 1490 | + | domain.CronSchedule{Preset: preset}, | |
| 1491 | + | func(jobCtx context.Context) error { | |
| 1492 | + | if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil { | |
| 1493 | + | return err | |
| 1494 | + | } | |
| 1495 | + | log.Info(). | |
| 1496 | + | Str("schedule", string(preset)). | |
| 1497 | + | Msg("scheduled backup run complete") | |
| 1498 | + | return nil | |
| 1499 | + | }, | |
| 1500 | + | ) | |
| 1501 | + | if err != nil { | |
| 1502 | + | return nil, log.WrapErr(err, "failed to register backup schedule") | |
| 1503 | + | } | |
| 1504 | + | ||
| 1505 | + | scheduler.Start(ctx) | |
| 1506 | + | log.Info(). | |
| 1507 | + | Str("schedule", string(preset)). | |
| 1508 | + | Msg("backup scheduler enabled") | |
| 1509 | + | ||
| 1510 | + | return scheduler, nil | |
| 1511 | + | } | |
| 1512 | + | ||
| 1513 | + | func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) { | |
| 1514 | + | schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw))) | |
| 1515 | + | if schedule == "" { | |
| 1516 | + | schedule = domain.ScheduleDaily | |
| 1517 | + | } | |
| 1518 | + | ||
| 1519 | + | switch schedule { | |
| 1520 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |
| 1521 | + | return schedule, nil | |
| 1522 | + | default: | |
| 1523 | + | return "", fmt.Errorf("backups.schedule must be one of: hourly, daily, weekly, monthly") | |
| 1524 | + | } | |
| 1525 | + | } | |
| 1526 | + | ||
| 1467 | 1527 | func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) { | |
| 1468 | 1528 | if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil { | |
| 1469 | 1529 | return nil, nil | |
| @@ -1951,6 +2011,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 1951 | 2011 | v.SetDefault("volumes.preserve", true) | |
| 1952 | 2012 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | |
| 1953 | 2013 | v.SetDefault("backups.enabled", false) | |
| 2014 | + | v.SetDefault("backups.schedule", string(domain.ScheduleDaily)) | |
| 1954 | 2015 | v.SetDefault("backups.storage_dir", "") | |
| 1955 | 2016 | v.SetDefault("backups.retention.hourly", 0) | |
| 1956 | 2017 | v.SetDefault("backups.retention.daily", 0) | |
| @@ -135,6 +135,7 @@ type Config struct { | |||||
| 135 | 135 | ||||
| 136 | Backups struct { | 136 | Backups struct { | ||
| 137 | Enabled bool `mapstructure:"enabled"` | 137 | Enabled bool `mapstructure:"enabled"` | ||
| 138 | + | Schedule string `mapstructure:"schedule"` | |||
| 138 | StorageDir string `mapstructure:"storage_dir"` | 139 | StorageDir string `mapstructure:"storage_dir"` | ||
| 139 | Retention struct { | 140 | Retention struct { | ||
| 140 | Hourly int `mapstructure:"hourly"` | 141 | Hourly int `mapstructure:"hourly"` | ||
| @@ -1446,6 +1447,14 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1446 | } | 1447 | } | ||
| 1447 | logEvent.Msg("Gordon is running") | 1448 | logEvent.Msg("Gordon is running") | ||
| 1448 | 1449 | ||||
| 1450 | + | backupScheduler, err := startBackupScheduler(ctx, cfg, svc, log) | |||
| 1451 | + | if err != nil { | |||
| 1452 | + | return err | |||
| 1453 | + | } | |||
| 1454 | + | if backupScheduler != nil { | |||
| 1455 | + | defer backupScheduler.Stop() | |||
| 1456 | + | } | |||
| 1457 | + | ||||
| 1449 | imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int { | 1458 | imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int { | ||
| 1450 | return v.GetInt("images.prune.keep_last") | 1459 | return v.GetInt("images.prune.keep_last") | ||
| 1451 | }) | 1460 | }) | ||
| @@ -1464,6 +1473,57 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler | |||||
| 1464 | return nil | 1473 | return nil | ||
| 1465 | } | 1474 | } | ||
| 1466 | 1475 | ||||
| 1476 | + | func startBackupScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) (*cronSvc.Scheduler, error) { | |||
| 1477 | + | if !cfg.Backups.Enabled || svc == nil || svc.backupSvc == nil { | |||
| 1478 | + | return nil, nil | |||
| 1479 | + | } | |||
| 1480 | + | ||||
| 1481 | + | preset, err := resolveBackupSchedule(cfg.Backups.Schedule) | |||
| 1482 | + | if err != nil { | |||
| 1483 | + | return nil, err | |||
| 1484 | + | } | |||
| 1485 | + | ||||
| 1486 | + | scheduler := cronSvc.NewScheduler(log) | |||
| 1487 | + | err = scheduler.Add( | |||
| 1488 | + | "backup-scheduler", | |||
| 1489 | + | "Backups", | |||
| 1490 | + | domain.CronSchedule{Preset: preset}, | |||
| 1491 | + | func(jobCtx context.Context) error { | |||
| 1492 | + | if err := svc.backupSvc.RunForSchedule(jobCtx, preset); err != nil { | |||
| 1493 | + | return err | |||
| 1494 | + | } | |||
| 1495 | + | log.Info(). | |||
| 1496 | + | Str("schedule", string(preset)). | |||
| 1497 | + | Msg("scheduled backup run complete") | |||
| 1498 | + | return nil | |||
| 1499 | + | }, | |||
| 1500 | + | ) | |||
| 1501 | + | if err != nil { | |||
| 1502 | + | return nil, log.WrapErr(err, "failed to register backup schedule") | |||
| 1503 | + | } | |||
| 1504 | + | ||||
| 1505 | + | scheduler.Start(ctx) | |||
| 1506 | + | log.Info(). | |||
| 1507 | + | Str("schedule", string(preset)). | |||
| 1508 | + | Msg("backup scheduler enabled") | |||
| 1509 | + | ||||
| 1510 | + | return scheduler, nil | |||
| 1511 | + | } | |||
| 1512 | + | ||||
| 1513 | + | func resolveBackupSchedule(raw string) (domain.BackupSchedule, error) { | |||
| 1514 | + | schedule := domain.BackupSchedule(strings.ToLower(strings.TrimSpace(raw))) | |||
| 1515 | + | if schedule == "" { | |||
| 1516 | + | schedule = domain.ScheduleDaily | |||
| 1517 | + | } | |||
| 1518 | + | ||||
| 1519 | + | switch schedule { | |||
| 1520 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |||
| 1521 | + | return schedule, nil | |||
| 1522 | + | default: | |||
| 1523 | + | return "", fmt.Errorf("backups.schedule must be one of: hourly, daily, weekly, monthly") | |||
| 1524 | + | } | |||
| 1525 | + | } | |||
| 1526 | + | ||||
| 1467 | func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) { | 1527 | func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) { | ||
| 1468 | if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil { | 1528 | if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil { | ||
| 1469 | return nil, nil | 1529 | return nil, nil | ||
| @@ -1951,6 +2011,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 1951 | v.SetDefault("volumes.preserve", true) | 2011 | v.SetDefault("volumes.preserve", true) | ||
| 1952 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | 2012 | v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged) | ||
| 1953 | v.SetDefault("backups.enabled", false) | 2013 | v.SetDefault("backups.enabled", false) | ||
| 2014 | + | v.SetDefault("backups.schedule", string(domain.ScheduleDaily)) | |||
| 1954 | v.SetDefault("backups.storage_dir", "") | 2015 | v.SetDefault("backups.storage_dir", "") | ||
| 1955 | v.SetDefault("backups.retention.hourly", 0) | 2016 | v.SetDefault("backups.retention.hourly", 0) | ||
| 1956 | v.SetDefault("backups.retention.daily", 0) | 2017 | v.SetDefault("backups.retention.daily", 0) | ||
M internal/app/run_backup_test.go +40 -0
| @@ -3,8 +3,11 @@ package app | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/spf13/viper" | |
| 6 | 7 | "github.com/stretchr/testify/assert" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | |
| @@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |||
| 86 | 89 | assert.Equal(t, 0, retention.Monthly) | |
| 87 | 90 | }) | |
| 88 | 91 | } | |
| 92 | + | ||
| 93 | + | func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) { | |
| 94 | + | v := viper.New() | |
| 95 | + | ||
| 96 | + | require.NoError(t, loadConfig(v, "")) | |
| 97 | + | ||
| 98 | + | assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule")) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestResolveBackupSchedule(t *testing.T) { | |
| 102 | + | t.Run("valid values", func(t *testing.T) { | |
| 103 | + | tests := []struct { | |
| 104 | + | input string | |
| 105 | + | want domain.BackupSchedule | |
| 106 | + | }{ | |
| 107 | + | {input: "hourly", want: domain.ScheduleHourly}, | |
| 108 | + | {input: "daily", want: domain.ScheduleDaily}, | |
| 109 | + | {input: "weekly", want: domain.ScheduleWeekly}, | |
| 110 | + | {input: "monthly", want: domain.ScheduleMonthly}, | |
| 111 | + | {input: " DAILY ", want: domain.ScheduleDaily}, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | for _, tt := range tests { | |
| 115 | + | t.Run(tt.input, func(t *testing.T) { | |
| 116 | + | got, err := resolveBackupSchedule(tt.input) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Equal(t, tt.want, got) | |
| 119 | + | }) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | t.Run("invalid value", func(t *testing.T) { | |
| 124 | + | _, err := resolveBackupSchedule("every-minute") | |
| 125 | + | require.Error(t, err) | |
| 126 | + | assert.Contains(t, err.Error(), "backups.schedule") | |
| 127 | + | }) | |
| 128 | + | } | |
| @@ -3,8 +3,11 @@ package app | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/spf13/viper" | |||
| 6 | "github.com/stretchr/testify/assert" | 7 | "github.com/stretchr/testify/assert" | ||
| 7 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | ) | 11 | ) | ||
| 9 | 12 | ||||
| 10 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | 13 | func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) { | ||
| @@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) { | |||||
| 86 | assert.Equal(t, 0, retention.Monthly) | 89 | assert.Equal(t, 0, retention.Monthly) | ||
| 87 | }) | 90 | }) | ||
| 88 | } | 91 | } | ||
| 92 | + | ||||
| 93 | + | func TestLoadConfigSetsBackupScheduleDefault(t *testing.T) { | |||
| 94 | + | v := viper.New() | |||
| 95 | + | ||||
| 96 | + | require.NoError(t, loadConfig(v, "")) | |||
| 97 | + | ||||
| 98 | + | assert.Equal(t, string(domain.ScheduleDaily), v.GetString("backups.schedule")) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func TestResolveBackupSchedule(t *testing.T) { | |||
| 102 | + | t.Run("valid values", func(t *testing.T) { | |||
| 103 | + | tests := []struct { | |||
| 104 | + | input string | |||
| 105 | + | want domain.BackupSchedule | |||
| 106 | + | }{ | |||
| 107 | + | {input: "hourly", want: domain.ScheduleHourly}, | |||
| 108 | + | {input: "daily", want: domain.ScheduleDaily}, | |||
| 109 | + | {input: "weekly", want: domain.ScheduleWeekly}, | |||
| 110 | + | {input: "monthly", want: domain.ScheduleMonthly}, | |||
| 111 | + | {input: " DAILY ", want: domain.ScheduleDaily}, | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | for _, tt := range tests { | |||
| 115 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 116 | + | got, err := resolveBackupSchedule(tt.input) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | assert.Equal(t, tt.want, got) | |||
| 119 | + | }) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | ||||
| 123 | + | t.Run("invalid value", func(t *testing.T) { | |||
| 124 | + | _, err := resolveBackupSchedule("every-minute") | |||
| 125 | + | require.Error(t, err) | |||
| 126 | + | assert.Contains(t, err.Error(), "backups.schedule") | |||
| 127 | + | }) | |||
| 128 | + | } | |||
M internal/usecase/backup/service.go +51 -1
| @@ -66,6 +66,47 @@ func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]dom | |||
| 66 | 66 | ||
| 67 | 67 | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | |
| 68 | 68 | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | |
| 69 | + | return s.runBackup(ctx, domainName, dbName, domain.BackupSchedule("")) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // RunForSchedule executes backups for all detected databases and applies retention. | |
| 73 | + | func (s *Service) RunForSchedule(ctx context.Context, schedule domain.BackupSchedule) error { | |
| 74 | + | if !isValidBackupSchedule(schedule) { | |
| 75 | + | return fmt.Errorf("invalid backup schedule: %q", schedule) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | routes := s.containerSvc.List(ctx) | |
| 79 | + | domainNames := make([]string, 0, len(routes)) | |
| 80 | + | for domainName := range routes { | |
| 81 | + | domainNames = append(domainNames, domainName) | |
| 82 | + | } | |
| 83 | + | sort.Strings(domainNames) | |
| 84 | + | ||
| 85 | + | var firstErr error | |
| 86 | + | for _, domainName := range domainNames { | |
| 87 | + | dbs, err := s.DetectDatabases(ctx, domainName) | |
| 88 | + | if err != nil { | |
| 89 | + | if firstErr == nil { | |
| 90 | + | firstErr = fmt.Errorf("detect databases for %s: %w", domainName, err) | |
| 91 | + | } | |
| 92 | + | continue | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | for _, db := range dbs { | |
| 96 | + | if _, err := s.runBackup(ctx, domainName, db.Name, schedule); err != nil && firstErr == nil { | |
| 97 | + | firstErr = fmt.Errorf("run backup for %s/%s: %w", domainName, db.Name, err) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | if _, err := s.storage.ApplyRetention(ctx, domainName, s.config.Retention); err != nil && firstErr == nil { | |
| 102 | + | firstErr = fmt.Errorf("apply retention for %s: %w", domainName, err) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | return firstErr | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (s *Service) runBackup(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule) (*domain.BackupResult, error) { | |
| 69 | 110 | started := time.Now().UTC() | |
| 70 | 111 | ||
| 71 | 112 | dbs, err := s.DetectDatabases(ctx, domainName) | |
| @@ -108,7 +149,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||
| 108 | 149 | defer dumpStream.Close() | |
| 109 | 150 | ||
| 110 | 151 | counter := &byteCounter{} | |
| 111 | - | path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, io.TeeReader(dumpStream, counter)) | |
| 152 | + | path, err := s.storage.Store(ctx, domainName, db.Name, schedule, started, io.TeeReader(dumpStream, counter)) | |
| 112 | 153 | if err != nil { | |
| 113 | 154 | return nil, err | |
| 114 | 155 | } | |
| @@ -131,6 +172,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||
| 131 | 172 | }, nil | |
| 132 | 173 | } | |
| 133 | 174 | ||
| 175 | + | func isValidBackupSchedule(schedule domain.BackupSchedule) bool { | |
| 176 | + | switch schedule { | |
| 177 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |
| 178 | + | return true | |
| 179 | + | default: | |
| 180 | + | return false | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 134 | 184 | // Restore restores a backup by ID. | |
| 135 | 185 | func (s *Service) Restore(context.Context, string, string) error { | |
| 136 | 186 | return fmt.Errorf("backup restore not implemented yet") | |
| @@ -66,6 +66,47 @@ func (s *Service) DetectDatabases(ctx context.Context, domainName string) ([]dom | |||||
| 66 | 66 | ||||
| 67 | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | 67 | // RunBackup triggers a logical PostgreSQL backup for a detected DB. | ||
| 68 | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | 68 | func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*domain.BackupResult, error) { | ||
| 69 | + | return s.runBackup(ctx, domainName, dbName, domain.BackupSchedule("")) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // RunForSchedule executes backups for all detected databases and applies retention. | |||
| 73 | + | func (s *Service) RunForSchedule(ctx context.Context, schedule domain.BackupSchedule) error { | |||
| 74 | + | if !isValidBackupSchedule(schedule) { | |||
| 75 | + | return fmt.Errorf("invalid backup schedule: %q", schedule) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | routes := s.containerSvc.List(ctx) | |||
| 79 | + | domainNames := make([]string, 0, len(routes)) | |||
| 80 | + | for domainName := range routes { | |||
| 81 | + | domainNames = append(domainNames, domainName) | |||
| 82 | + | } | |||
| 83 | + | sort.Strings(domainNames) | |||
| 84 | + | ||||
| 85 | + | var firstErr error | |||
| 86 | + | for _, domainName := range domainNames { | |||
| 87 | + | dbs, err := s.DetectDatabases(ctx, domainName) | |||
| 88 | + | if err != nil { | |||
| 89 | + | if firstErr == nil { | |||
| 90 | + | firstErr = fmt.Errorf("detect databases for %s: %w", domainName, err) | |||
| 91 | + | } | |||
| 92 | + | continue | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | for _, db := range dbs { | |||
| 96 | + | if _, err := s.runBackup(ctx, domainName, db.Name, schedule); err != nil && firstErr == nil { | |||
| 97 | + | firstErr = fmt.Errorf("run backup for %s/%s: %w", domainName, db.Name, err) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | if _, err := s.storage.ApplyRetention(ctx, domainName, s.config.Retention); err != nil && firstErr == nil { | |||
| 102 | + | firstErr = fmt.Errorf("apply retention for %s: %w", domainName, err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | return firstErr | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (s *Service) runBackup(ctx context.Context, domainName, dbName string, schedule domain.BackupSchedule) (*domain.BackupResult, error) { | |||
| 69 | started := time.Now().UTC() | 110 | started := time.Now().UTC() | ||
| 70 | 111 | ||||
| 71 | dbs, err := s.DetectDatabases(ctx, domainName) | 112 | dbs, err := s.DetectDatabases(ctx, domainName) | ||
| @@ -108,7 +149,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||||
| 108 | defer dumpStream.Close() | 149 | defer dumpStream.Close() | ||
| 109 | 150 | ||||
| 110 | counter := &byteCounter{} | 151 | counter := &byteCounter{} | ||
| 111 | - | path, err := s.storage.Store(ctx, domainName, db.Name, domain.BackupSchedule(""), started, io.TeeReader(dumpStream, counter)) | 152 | + | path, err := s.storage.Store(ctx, domainName, db.Name, schedule, started, io.TeeReader(dumpStream, counter)) |
| 112 | if err != nil { | 153 | if err != nil { | ||
| 113 | return nil, err | 154 | return nil, err | ||
| 114 | } | 155 | } | ||
| @@ -131,6 +172,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||||
| 131 | }, nil | 172 | }, nil | ||
| 132 | } | 173 | } | ||
| 133 | 174 | ||||
| 175 | + | func isValidBackupSchedule(schedule domain.BackupSchedule) bool { | |||
| 176 | + | switch schedule { | |||
| 177 | + | case domain.ScheduleHourly, domain.ScheduleDaily, domain.ScheduleWeekly, domain.ScheduleMonthly: | |||
| 178 | + | return true | |||
| 179 | + | default: | |||
| 180 | + | return false | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 134 | // Restore restores a backup by ID. | 184 | // Restore restores a backup by ID. | ||
| 135 | func (s *Service) Restore(context.Context, string, string) error { | 185 | func (s *Service) Restore(context.Context, string, string) error { | ||
| 136 | return fmt.Errorf("backup restore not implemented yet") | 186 | return fmt.Errorf("backup restore not implemented yet") | ||
M internal/usecase/backup/service_test.go +56 -0
| @@ -177,3 +177,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi | |||
| 177 | 177 | t.Fatal("Status did not return after context cancellation while waiting for semaphore") | |
| 178 | 178 | } | |
| 179 | 179 | } | |
| 180 | + | ||
| 181 | + | func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) { | |
| 182 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 183 | + | storage := outmocks.NewMockBackupStorage(t) | |
| 184 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 185 | + | ||
| 186 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |
| 187 | + | "app.example.com": {}, | |
| 188 | + | }) | |
| 189 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |
| 190 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"}, | |
| 191 | + | }) | |
| 192 | + | ||
| 193 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |
| 194 | + | if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" { | |
| 195 | + | return false | |
| 196 | + | } | |
| 197 | + | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) | |
| 198 | + | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |
| 199 | + | ||
| 200 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool { | |
| 201 | + | return path != "" | |
| 202 | + | })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil) | |
| 203 | + | ||
| 204 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |
| 205 | + | return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f")) | |
| 206 | + | })).Return(&outiface.ExecResult{ExitCode: 0}, nil) | |
| 207 | + | ||
| 208 | + | storage.EXPECT().Store( | |
| 209 | + | mock.Anything, | |
| 210 | + | "app.example.com", | |
| 211 | + | "postgres", | |
| 212 | + | domain.ScheduleDaily, | |
| 213 | + | mock.Anything, | |
| 214 | + | mock.Anything, | |
| 215 | + | ).Return("/tmp/daily-backup.bak", nil) | |
| 216 | + | ||
| 217 | + | storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil) | |
| 218 | + | ||
| 219 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default()) | |
| 220 | + | ||
| 221 | + | err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily) | |
| 222 | + | require.NoError(t, err) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) { | |
| 226 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 227 | + | storage := outmocks.NewMockBackupStorage(t) | |
| 228 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 229 | + | ||
| 230 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |
| 231 | + | ||
| 232 | + | err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute")) | |
| 233 | + | require.Error(t, err) | |
| 234 | + | assert.Contains(t, err.Error(), "invalid backup schedule") | |
| 235 | + | } | |
| @@ -177,3 +177,59 @@ func TestServiceStatusReturnsWhenContextCancelledDuringSemaphoreAcquire(t *testi | |||||
| 177 | t.Fatal("Status did not return after context cancellation while waiting for semaphore") | 177 | t.Fatal("Status did not return after context cancellation while waiting for semaphore") | ||
| 178 | } | 178 | } | ||
| 179 | } | 179 | } | ||
| 180 | + | ||||
| 181 | + | func TestService_RunForSchedule_StoresTierAndAppliesRetention(t *testing.T) { | |||
| 182 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 183 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 184 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 185 | + | ||||
| 186 | + | containerSvc.EXPECT().List(mock.Anything).Return(map[string]*domain.Container{ | |||
| 187 | + | "app.example.com": {}, | |||
| 188 | + | }) | |||
| 189 | + | containerSvc.EXPECT().ListAttachments(mock.Anything, "app.example.com").Return([]domain.Attachment{ | |||
| 190 | + | {Name: "postgres", Image: "postgres:17", ContainerID: "db123", Status: "running"}, | |||
| 191 | + | }) | |||
| 192 | + | ||||
| 193 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |||
| 194 | + | if len(cmd) != 3 || cmd[0] != "sh" || cmd[1] != "-c" { | |||
| 195 | + | return false | |||
| 196 | + | } | |||
| 197 | + | return bytes.Contains([]byte(cmd[2]), []byte("pg_dump -Fc")) | |||
| 198 | + | })).Return(&outiface.ExecResult{ExitCode: 0, Stdout: []byte("backup-data")}, nil) | |||
| 199 | + | ||||
| 200 | + | runtime.EXPECT().CopyFromContainer(mock.Anything, "db123", mock.MatchedBy(func(path string) bool { | |||
| 201 | + | return path != "" | |||
| 202 | + | })).Return(io.NopCloser(bytes.NewReader([]byte("backup-data"))), nil) | |||
| 203 | + | ||||
| 204 | + | runtime.EXPECT().ExecInContainer(mock.Anything, "db123", mock.MatchedBy(func(cmd []string) bool { | |||
| 205 | + | return len(cmd) == 3 && cmd[0] == "sh" && cmd[1] == "-c" && bytes.Contains([]byte(cmd[2]), []byte("rm -f")) | |||
| 206 | + | })).Return(&outiface.ExecResult{ExitCode: 0}, nil) | |||
| 207 | + | ||||
| 208 | + | storage.EXPECT().Store( | |||
| 209 | + | mock.Anything, | |||
| 210 | + | "app.example.com", | |||
| 211 | + | "postgres", | |||
| 212 | + | domain.ScheduleDaily, | |||
| 213 | + | mock.Anything, | |||
| 214 | + | mock.Anything, | |||
| 215 | + | ).Return("/tmp/daily-backup.bak", nil) | |||
| 216 | + | ||||
| 217 | + | storage.EXPECT().ApplyRetention(mock.Anything, "app.example.com", domain.RetentionPolicy{Daily: 7}).Return(0, nil) | |||
| 218 | + | ||||
| 219 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{Retention: domain.RetentionPolicy{Daily: 7}}, zerowrap.Default()) | |||
| 220 | + | ||||
| 221 | + | err := svc.RunForSchedule(context.Background(), domain.ScheduleDaily) | |||
| 222 | + | require.NoError(t, err) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func TestService_RunForSchedule_RejectsInvalidSchedule(t *testing.T) { | |||
| 226 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 227 | + | storage := outmocks.NewMockBackupStorage(t) | |||
| 228 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 229 | + | ||||
| 230 | + | svc := NewService(runtime, storage, containerSvc, domain.BackupConfig{}, zerowrap.Default()) | |||
| 231 | + | ||||
| 232 | + | err := svc.RunForSchedule(context.Background(), domain.BackupSchedule("every-minute")) | |||
| 233 | + | require.Error(t, err) | |||
| 234 | + | assert.Contains(t, err.Error(), "invalid backup schedule") | |||
| 235 | + | } | |||