feat(backups): scheduled database backups via cron tick

- database_services gains backup_schedule + next_backup_at (goose migration,
  sqlc schema, new queries).
- PUT /databases/:id accepts backup_schedule — validated cron expression,
  empty string disables; invalid returns 400.
- Cron scheduler tick now also folds due databases into the existing
  snapshot pipeline (createBackupProcess).
- Databases page backups section gets a schedule row showing current expr
  and next run.
- resolveBackupConfig reports real next_backup/enabled instead of a fake
  last+24h estimate.
- Verified e2e: '* * * * *' produced three consecutive completed archives
  containing the seeded payload; invalid schedule rejected.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:46:02 +02:00
1 parent aeb91b03e9
commit 08d92fb10f
13 files changed
+387 -99

No files matched your search

+48
View File
@@ -2,8 +2,10 @@ package api
import (
"containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker"
"context"
"database/sql"
"fmt"
"log"
"net/http"
@@ -436,6 +438,7 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
return
case <-ticker.C:
runDueCronJobs(db, dockerClient)
runDueDatabaseBackups(db, dockerClient)
}
}
}()
@@ -478,6 +481,51 @@ func runDueCronJobs(db *database.DB, dockerClient *docker.Client) {
}
}
// runDueDatabaseBackups snapshots managed databases whose backup_schedule is
// due, reusing the same archive pipeline as manual backups.
func runDueDatabaseBackups(db *database.DB, dockerClient *docker.Client) {
if dockerClient == nil {
return
}
q := sqlcdb.New(db.DB)
due, err := q.ListDueDatabaseBackups(context.Background())
if err != nil {
log.Printf("cron scheduler: failed to list due database backups: %v", err)
return
}
handler := NewDatabaseHandler(db.DB, dockerClient)
for _, row := range due {
schedule := row.BackupSchedule.String
next, err := calculateNextRun(schedule, "UTC")
if err != nil {
log.Printf("cron scheduler: invalid backup schedule %q on %s: %v", schedule, row.ID, err)
continue
}
now := time.Now()
if err := q.SetDatabaseNextBackupAt(context.Background(), sqlcdb.SetDatabaseNextBackupAtParams{
NextBackupAt: sql.NullTime{Time: *next, Valid: true},
ID: row.ID,
}); err != nil {
log.Printf("cron scheduler: failed to advance backup schedule on %s: %v", row.ID, err)
continue
}
backupID := generateDatabaseBackupID(row.ID)
archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID))
if err := q.CreateDatabaseBackup(context.Background(), sqlcdb.CreateDatabaseBackupParams{
ID: backupID,
DatabaseID: row.ID,
Size: "pending",
Status: "in_progress",
BackupPath: sql.NullString{String: archivePath, Valid: true},
CreatedAt: sql.NullTime{Time: now, Valid: true},
}); err != nil {
log.Printf("cron scheduler: failed to record backup for %s: %v", row.ID, err)
continue
}
go handler.createBackupProcess(row.ID, backupID, archivePath)
}
}
// findServiceContainer returns the first running container whose name matches
// the deployment naming convention containr-<serviceID>-*.
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {