Skip to content

Commit 4914364

Browse files
feat: add support for restore schedules
1 parent d42d77b commit 4914364

6 files changed

Lines changed: 419 additions & 26 deletions

File tree

README.md

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,54 @@ compress {
121121
compress_level = 12
122122
}
123123
124-
# backup schedule, required when using `storage-schedule` command
124+
# backup schedules, required when using `schedule run` command
125125
# see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format for more information
126126
schedule = [
127-
"0 1 * * *",
127+
"0 1 * * *", # Daily at 1 AM
128+
"0 13 * * *", # Daily at 1 PM
128129
]
129130
131+
# restore schedules (optional) - automatically restore backups on schedule
132+
# useful for refreshing test/staging databases
133+
restore_schedule {
134+
# cron expression for when to run the restore
135+
cron = "0 3 * * 0" # Weekly on Sunday at 3 AM
136+
137+
# target database name to restore to
138+
target_database = "test_db"
139+
140+
# backup selection strategy: "latest", "pattern", or "specific"
141+
backup_selection = "latest"
142+
143+
# include S3 backups in selection (optional, default true)
144+
include_s3 = true
145+
146+
# include local backups in selection (optional, default true)
147+
include_local = true
148+
149+
# enable/disable this restore schedule (optional, default true)
150+
enabled = true
151+
}
152+
153+
# example: restore backups matching a pattern
154+
restore_schedule {
155+
cron = "0 4 * * 1" # Weekly on Monday at 4 AM
156+
target_database = "staging_db"
157+
backup_selection = "pattern"
158+
backup_pattern = "2024-08" # restore backups containing "2024-08"
159+
include_s3 = true
160+
include_local = false
161+
}
162+
163+
# example: restore a specific backup
164+
restore_schedule {
165+
cron = "0 2 15 * *" # Monthly on 15th at 2 AM
166+
target_database = "monthly_test_db"
167+
backup_selection = "specific"
168+
backup_id = "2024-01-15T01:00:00" # specific backup timestamp
169+
enabled = false # disabled by default
170+
}
171+
130172
# verbose mode
131173
verbose = false
132174
```

cmd/restore.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,7 @@ Examples:
9191

9292
// Handle list-only mode
9393
if restoreListOnly {
94-
if err := listAvailableBackups(cmd.Context(), s3Configured, localConfigured); err != nil {
95-
logger.Fatal().Err(err).Msg("failed to list available backups")
96-
}
94+
listAvailableBackups(cmd.Context(), s3Configured, localConfigured)
9795
return
9896
}
9997

@@ -109,6 +107,7 @@ Examples:
109107
}
110108
if selectedBackup == nil {
111109
logger.Fatal().Msg("no backups found")
110+
return // This line will never execute, but helps staticcheck understand
112111
}
113112
logger.Info().Str("backup", selectedBackup.Name).Str("source", selectedBackup.Source).Msg("selected latest backup")
114113
} else if restoreBackupID != "" {
@@ -119,6 +118,7 @@ Examples:
119118
}
120119
if selectedBackup == nil {
121120
logger.Fatal().Str("backup_id", restoreBackupID).Msg("backup not found")
121+
return // This line will never execute, but helps staticcheck understand
122122
}
123123
logger.Info().Str("backup", selectedBackup.Name).Str("source", selectedBackup.Source).Msg("found specified backup")
124124
} else {
@@ -226,7 +226,7 @@ func listLocalBackups(logger zerolog.Logger) []BackupEntry {
226226
return backups
227227
}
228228

229-
func listAvailableBackups(ctx context.Context, listS3, listLocal bool) error {
229+
func listAvailableBackups(ctx context.Context, listS3, listLocal bool) {
230230
logger := log.Logger.With().Str("caller", "list_backups").Logger()
231231

232232
logger.Info().
@@ -257,7 +257,7 @@ func listAvailableBackups(ctx context.Context, listS3, listLocal bool) error {
257257

258258
if len(allBackups) == 0 {
259259
fmt.Fprintln(os.Stdout, "No backups found.")
260-
return nil
260+
return
261261
}
262262

263263
fmt.Fprintf(os.Stdout, "%-30s %-10s %-15s %s\n", "BACKUP NAME", "SOURCE", "SIZE", "CREATED")
@@ -270,8 +270,6 @@ func listAvailableBackups(ctx context.Context, listS3, listLocal bool) error {
270270
}
271271

272272
fmt.Fprintf(os.Stdout, "\nTotal: %d backups\n", len(allBackups))
273-
274-
return nil
275273
}
276274

277275
// formatSize formats a size in bytes to a human-readable string
@@ -365,19 +363,19 @@ func findLatestBackup(ctx context.Context, includeS3, includeLocal bool) (*Backu
365363
}
366364

367365
// findBackupByID finds a specific backup by ID (timestamp or filename)
368-
func findBackupByID(ctx context.Context, backupId string, includeS3, includeLocal bool) (*BackupEntry, error) {
366+
func findBackupByID(ctx context.Context, backupID string, includeS3, includeLocal bool) (*BackupEntry, error) {
369367
backups, err := getAllBackups(ctx, includeS3, includeLocal)
370368
if err != nil {
371369
return nil, err
372370
}
373371

374372
for _, backup := range backups {
375373
// Check if backup name contains the ID (partial match for timestamp)
376-
if strings.Contains(backup.Name, backupId) {
374+
if strings.Contains(backup.Name, backupID) {
377375
return &backup, nil
378376
}
379377
// Also check exact name match
380-
if backup.Name == backupId {
378+
if backup.Name == backupID {
381379
return &backup, nil
382380
}
383381
}

cmd/schedule/run.go

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,79 @@ import (
1414
// runCmd represents the run command
1515
var runCmd = &cobra.Command{
1616
Use: "run",
17-
Short: "Run the backup schedule",
18-
Long: `Run the backup schedule defined in the configuration file.`,
17+
Short: "Run the backup and restore schedules",
18+
Long: `Run the backup and restore schedules defined in the configuration file.`,
1919
Run: func(_ *cobra.Command, _ []string) {
2020
logger := log.Logger.With().Str("caller", "schedule_runner").Logger()
2121

22-
if len(config.Loaded.Schedule) == 0 {
23-
logger.Fatal().Msg("no backup schedules configured - cannot start scheduler")
22+
backupCount := len(config.Loaded.Schedule)
23+
restoreCount := len(config.Loaded.RestoreSchedule)
24+
totalSchedules := backupCount + restoreCount
25+
26+
if totalSchedules == 0 {
27+
logger.Fatal().Msg("no schedules configured - cannot start scheduler")
2428
}
2529

26-
logger.Info().Int("schedule_count", len(config.Loaded.Schedule)).Msg("initializing backup scheduler")
30+
logger.Info().
31+
Int("backup_schedules", backupCount).
32+
Int("restore_schedules", restoreCount).
33+
Int("total_schedules", totalSchedules).
34+
Msg("initializing scheduler")
2735

2836
c := cron.New()
37+
38+
// Register backup schedules
2939
for _, schedule := range config.Loaded.Schedule {
3040
if id, err := c.AddFunc(schedule, func() { internal.Backup(context.Background()) }); err != nil {
3141
logger.Fatal().Err(err).
3242
Str("cron_expression", schedule).
3343
Msg("failed to register backup schedule - invalid cron expression")
3444
} else {
3545
logger.Info().
46+
Str("type", "backup").
3647
Str("cron_expression", schedule).
3748
Str("next_run", c.Entry(id).Next.String()).
38-
Msg("backup schedule registered successfully")
49+
Msg("schedule registered successfully")
50+
}
51+
}
52+
53+
// Register restore schedules
54+
for _, restoreSchedule := range config.Loaded.RestoreSchedule {
55+
if !restoreSchedule.IsEnabled() {
56+
logger.Info().
57+
Str("type", "restore").
58+
Str("cron_expression", restoreSchedule.Cron).
59+
Str("target_database", restoreSchedule.TargetDatabase).
60+
Msg("restore schedule disabled, skipping")
61+
continue
62+
}
63+
64+
// Create a closure to capture the restore schedule config
65+
scheduleConfig := restoreSchedule // Important: capture the value, not the reference
66+
if id, err := c.AddFunc(restoreSchedule.Cron, func() {
67+
if err := internal.ScheduledRestore(context.Background(), scheduleConfig); err != nil {
68+
log.Error().Err(err).
69+
Str("cron_expression", scheduleConfig.Cron).
70+
Str("target_database", scheduleConfig.TargetDatabase).
71+
Msg("scheduled restore failed")
72+
}
73+
}); err != nil {
74+
logger.Fatal().Err(err).
75+
Str("cron_expression", restoreSchedule.Cron).
76+
Str("target_database", restoreSchedule.TargetDatabase).
77+
Msg("failed to register restore schedule - invalid cron expression")
78+
} else {
79+
logger.Info().
80+
Str("type", "restore").
81+
Str("cron_expression", restoreSchedule.Cron).
82+
Str("target_database", restoreSchedule.TargetDatabase).
83+
Str("backup_selection", restoreSchedule.BackupSelection).
84+
Str("next_run", c.Entry(id).Next.String()).
85+
Msg("schedule registered successfully")
3986
}
4087
}
4188

42-
logger.Info().Msg("starting backup scheduler - waiting for scheduled backup jobs")
89+
logger.Info().Msg("starting scheduler - waiting for scheduled jobs")
4390
c.Run()
4491
},
4592
}

internal/config/config.go

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,36 @@ import (
1111

1212
var Loaded *Config
1313

14-
type Config struct {
15-
Postgres PostgresConfig `hcl:"postgres,block"`
16-
Storage storage.Storage `hcl:"storage,block"`
17-
Compress *CompressConfig `hcl:"compress,block"`
14+
type RestoreScheduleConfig struct {
15+
Cron string `hcl:"cron"`
16+
TargetDatabase string `hcl:"target_database"`
17+
BackupSelection string `hcl:"backup_selection"` // "latest", "pattern", "specific"
18+
BackupPattern *string `hcl:"backup_pattern"` // optional: for pattern-based selection
19+
BackupID *string `hcl:"backup_id"` // optional: for specific backup selection
20+
IncludeS3 *bool `hcl:"include_s3"`
21+
IncludeLocal *bool `hcl:"include_local"`
22+
Enabled *bool `hcl:"enabled"`
23+
}
24+
25+
func (r RestoreScheduleConfig) IsEnabled() bool {
26+
return r.Enabled == nil || *r.Enabled
27+
}
1828

19-
Schedule []string `hcl:"schedule"`
29+
func (r RestoreScheduleConfig) ShouldIncludeS3() bool {
30+
return r.IncludeS3 == nil || *r.IncludeS3
31+
}
32+
33+
func (r RestoreScheduleConfig) ShouldIncludeLocal() bool {
34+
return r.IncludeLocal == nil || *r.IncludeLocal
35+
}
2036

21-
Verbose *bool `hcl:"verbose"`
37+
type Config struct {
38+
Postgres PostgresConfig `hcl:"postgres,block"`
39+
Storage storage.Storage `hcl:"storage,block"`
40+
Compress *CompressConfig `hcl:"compress,block"`
41+
Schedule []string `hcl:"schedule"`
42+
RestoreSchedule []RestoreScheduleConfig `hcl:"restore_schedule,block"`
43+
Verbose *bool `hcl:"verbose"`
2244
}
2345

2446
func (c Config) IsVerbose() bool {
@@ -63,6 +85,61 @@ func (c Config) Validate() error {
6385
}
6486
}
6587

88+
// Validate restore schedules
89+
for i, restoreSchedule := range c.RestoreSchedule {
90+
if err := c.validateRestoreSchedule(restoreSchedule, i); err != nil {
91+
return err
92+
}
93+
}
94+
95+
return nil
96+
}
97+
98+
func (c Config) validateRestoreSchedule(rs RestoreScheduleConfig, index int) error {
99+
// Validate required fields
100+
if rs.Cron == "" {
101+
return fmt.Errorf("restore_schedule[%d]: cron expression is required", index)
102+
}
103+
104+
if rs.TargetDatabase == "" {
105+
return fmt.Errorf("restore_schedule[%d]: target_database is required", index)
106+
}
107+
108+
if rs.BackupSelection == "" {
109+
return fmt.Errorf("restore_schedule[%d]: backup_selection is required", index)
110+
}
111+
112+
// Validate backup_selection values
113+
validSelections := []string{"latest", "pattern", "specific"}
114+
validSelection := false
115+
for _, valid := range validSelections {
116+
if rs.BackupSelection == valid {
117+
validSelection = true
118+
break
119+
}
120+
}
121+
if !validSelection {
122+
return fmt.Errorf("restore_schedule[%d]: backup_selection must be one of %v, got '%s'",
123+
index, validSelections, rs.BackupSelection)
124+
}
125+
126+
// Validate selection-specific requirements
127+
switch rs.BackupSelection {
128+
case "pattern":
129+
if rs.BackupPattern == nil || *rs.BackupPattern == "" {
130+
return fmt.Errorf("restore_schedule[%d]: backup_pattern is required when backup_selection is 'pattern'", index)
131+
}
132+
case "specific":
133+
if rs.BackupID == nil || *rs.BackupID == "" {
134+
return fmt.Errorf("restore_schedule[%d]: backup_id is required when backup_selection is 'specific'", index)
135+
}
136+
}
137+
138+
// Validate that at least one storage source is enabled
139+
if !rs.ShouldIncludeS3() && !rs.ShouldIncludeLocal() {
140+
return fmt.Errorf("restore_schedule[%d]: at least one of include_s3 or include_local must be true", index)
141+
}
142+
66143
return nil
67144
}
68145

internal/dump.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ type Process struct {
1616
cmd *exec.Cmd
1717

1818
stdout io.ReadCloser
19-
done chan struct{}
2019
}
2120

2221
func (p *Process) Start() error {

0 commit comments

Comments
 (0)