detached f6bf3a9

feat(backup): wire scheduled backup execution and retention

commit
f6bf3a976ee3d852196e93d34074f106f0fcc0c4
author
brice · · 2026-02-08 09:50 UTC
parent
df21944
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +215 -1
M docs/config/backups.md +3 -0
@@ -21,6 +21,7 @@ Current scope:
2121 ```toml
2222 [backups]
2323 enabled = true
24+schedule = "daily"
2425 storage_dir = "~/.gordon/backups"
2526
2627 [backups.retention]
@@ -35,6 +36,7 @@ monthly = 12
3536 | Key | Type | Default | Description |
3637 |-----|------|---------|-------------|
3738 | `backups.enabled` | bool | `false` | Enables backup service wiring |
39+| `backups.schedule` | string | `"daily"` | Scheduler preset: `hourly`, `daily`, `weekly`, `monthly` |
3840 | `backups.storage_dir` | string | `""` | Root backup directory. If empty, defaults to `{server.data_dir}/backups` |
3941 | `backups.retention.hourly` | int | `0` | Number of hourly backups to keep per DB |
4042 | `backups.retention.daily` | int | `0` | Number of daily backups to keep per DB |
@@ -58,6 +60,7 @@ Example:
5860 ## Notes
5961
6062 - Backups require PostgreSQL attachment containers to be running.
63+- Scheduled backups run only when `backups.enabled = true`.
6164 - The backup command executes inside the database container using Gordon runtime APIs.
6265 - Retention applies per database and per schedule.
6366 - `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 ```toml21 ```toml
22 [backups]22 [backups]
23 enabled = true23 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 ## Notes60 ## 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
119119 # Backups
120120 [backups]
121121 enabled = true
122+schedule = "daily"
122123 storage_dir = "~/.gordon/backups"
123124
124125 [backups.retention]
@@ -189,6 +190,7 @@ keep_last = 3
189190 | `network_isolation.enabled` | `false` |
190191 | `auto_route.enabled` | `false` |
191192 | `backups.enabled` | `false` |
193+| `backups.schedule` | `"daily"` |
192194 | `images.prune.enabled` | `false` |
193195 | `images.prune.schedule` | `"daily"` |
194196 | `images.prune.keep_last` | `3` |
@@ -119,6 +119,7 @@ enabled = false # Auto-create routes from image names
119 # Backups119 # Backups
120 [backups]120 [backups]
121 enabled = true121 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
133133 # =============================================================================
134134 [backups]
135135 enabled = false # Enable backup service
136+schedule = "daily" # "hourly", "daily", "weekly", "monthly"
136137 storage_dir = "" # Backup root (default: {data_dir}/backups)
137138
138139 [backups.retention]
@@ -200,6 +201,7 @@ keep_last = 3 # Keep N newest tags per repository
200201 | `volumes.prefix` | `"gordon"` | Volume prefix |
201202 | `volumes.preserve` | `true` | Keep volumes |
202203 | `backups.enabled` | `false` | Backup service disabled |
204+| `backups.schedule` | `"daily"` | Backup scheduler preset |
203205 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
204206 | `backups.retention.hourly` | `0` | Keep no hourly backups by default |
205207 | `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 service135 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 {
135135
136136 Backups struct {
137137 Enabled bool `mapstructure:"enabled"`
138+ Schedule string `mapstructure:"schedule"`
138139 StorageDir string `mapstructure:"storage_dir"`
139140 Retention struct {
140141 Hourly int `mapstructure:"hourly"`
@@ -1446,6 +1447,14 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
14461447 }
14471448 logEvent.Msg("Gordon is running")
14481449
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+
14491458 imageScheduler, err := startImagePruneScheduler(ctx, cfg, svc, log, func() int {
14501459 return v.GetInt("images.prune.keep_last")
14511460 })
@@ -1464,6 +1473,57 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
14641473 return nil
14651474 }
14661475
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+
14671527 func startImagePruneScheduler(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger, keepLastGetter func() int) (*cronSvc.Scheduler, error) {
14681528 if !cfg.Images.Prune.Enabled || svc == nil || svc.imageSvc == nil {
14691529 return nil, nil
@@ -1951,6 +2011,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
19512011 v.SetDefault("volumes.preserve", true)
19522012 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
19532013 v.SetDefault("backups.enabled", false)
2014+ v.SetDefault("backups.schedule", string(domain.ScheduleDaily))
19542015 v.SetDefault("backups.storage_dir", "")
19552016 v.SetDefault("backups.retention.hourly", 0)
19562017 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 nil1473 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, nil1529 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
33 import (
44 "testing"
55
6+ "github.com/spf13/viper"
67 "github.com/stretchr/testify/assert"
78 "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/domain"
811 )
912
1013 func TestValidateBackupRetentionRejectsNegativeValues(t *testing.T) {
@@ -86,3 +89,40 @@ func TestValidateBackupRetentionAcceptsZeroAndPositiveValues(t *testing.T) {
8689 assert.Equal(t, 0, retention.Monthly)
8790 })
8891 }
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
6666
6767 // RunBackup triggers a logical PostgreSQL backup for a detected DB.
6868 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) {
69110 started := time.Now().UTC()
70111
71112 dbs, err := s.DetectDatabases(ctx, domainName)
@@ -108,7 +149,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
108149 defer dumpStream.Close()
109150
110151 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))
112153 if err != nil {
113154 return nil, err
114155 }
@@ -131,6 +172,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
131172 }, nil
132173 }
133174
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+
134184 // Restore restores a backup by ID.
135185 func (s *Service) Restore(context.Context, string, string) error {
136186 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, err154 return nil, err
114 }155 }
@@ -131,6 +172,15 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
131 }, nil172 }, 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
177177 t.Fatal("Status did not return after context cancellation while waiting for semaphore")
178178 }
179179 }
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+}

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