From 08d92fb10fd73e723118025f019ea4f68b8361f0 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Thu, 17 Sep 2026 17:46:02 +0200 Subject: [PATCH] feat(backups): scheduled database backups via cron tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 9 +- app/backend/internal/api/cron.go | 48 ++++++ app/backend/internal/api/databases.go | 158 +++++++++++------- .../internal/database/sqlcdb/databases.sql.go | 128 +++++++++++--- .../internal/database/sqlcdb/models.go | 24 +-- .../internal/database/sqlcdb/querier.go | 3 + ...0260919000000_database_backup_schedule.sql | 9 + app/backend/sqlc/queries/databases.sql | 20 ++- app/backend/sqlc/schema.sql | 2 + app/frontend/src/features/secondary/pages.tsx | 58 +++++++ app/frontend/src/generated/api-types.ts | 9 + app/frontend/src/lib/api-client.ts | 7 + docs/api/openapi.yaml | 11 ++ 13 files changed, 387 insertions(+), 99 deletions(-) create mode 100644 app/backend/migrations_goose/20260919000000_database_backup_schedule.sql diff --git a/ROADMAP.md b/ROADMAP.md index 670f419..2ebdce7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -332,9 +332,12 @@ All of these have working APIs and no UI. Cheapest feature wins in the repo. through logs was not viable). Download action in the `/databases` backups list. Verified e2e: byte-identical archive, 404 missing/cross-user, 401 unauthenticated. -- [ ] **Scheduled database backups**: backups are manual-only despite a - `retention` field. Add a `schedule` column + fold due database backups - into the cron scheduler tick. +- [x] **Scheduled database backups**: `database_services.backup_schedule` + (cron expr) + `next_backup_at`; `PUT /databases/:id` accepts + `backup_schedule` (validated, empty string disables). The cron tick folds + due databases into `createBackupProcess`. Schedule row in the + `/databases` backups section. Verified e2e: `* * * * *` produced three + consecutive completed archives with real volume data. **Gate**: every registered route is reachable from the UI or has an explicit "API-only" note in the spec. A request to `/g/{service}` with a valid key diff --git a/app/backend/internal/api/cron.go b/app/backend/internal/api/cron.go index 097a150..7f5f7f6 100644 --- a/app/backend/internal/api/cron.go +++ b/app/backend/internal/api/cron.go @@ -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--*. func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) { diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index e4d7fd6..35c728e 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -47,6 +47,8 @@ type DatabaseService struct { Version string `json:"version" db:"version"` Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business Region string `json:"region" db:"region"` + BackupSchedule string `json:"backup_schedule,omitempty"` + NextBackupAt *time.Time `json:"next_backup_at,omitempty"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` ConnectionURL string `json:"connection_url"` @@ -102,8 +104,9 @@ type DatabaseCreateRequest struct { // DatabaseUpdateRequest represents a request to update a database type DatabaseUpdateRequest struct { - Name string `json:"name,omitempty"` - Plan string `json:"plan,omitempty"` + Name string `json:"name,omitempty"` + Plan string `json:"plan,omitempty"` + BackupSchedule *string `json:"backup_schedule,omitempty"` } // DatabaseActionRequest represents a request to perform database actions @@ -183,7 +186,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) { db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() @@ -220,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) { db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() @@ -364,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.Plan = strings.TrimSpace(req.Plan) - if req.Name == "" && req.Plan == "" { + if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } - now := sql.NullTime{Time: time.Now(), Valid: true} - var err error - switch { - case req.Name != "" && req.Plan != "": - err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ - Name: req.Name, - Plan: req.Plan, - UpdatedAt: now, - ID: databaseID, - UserID: userID, - }) - case req.Name != "": - err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ - Name: req.Name, - UpdatedAt: now, - ID: databaseID, - UserID: userID, - }) - default: - err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{ - Plan: req.Plan, - UpdatedAt: now, - ID: databaseID, - UserID: userID, + if req.BackupSchedule != nil { + var schedule sql.NullString + var nextBackup sql.NullTime + if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" { + next, err := calculateNextRun(expr, "UTC") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"}) + return + } + schedule = sql.NullString{String: expr, Valid: true} + nextBackup = sql.NullTime{Time: *next, Valid: true} + } + err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{ + BackupSchedule: schedule, + NextBackupAt: nextBackup, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: databaseID, + UserID: userID, }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup schedule"}) + return + } } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) - return + + if req.Name != "" || req.Plan != "" { + now := sql.NullTime{Time: time.Now(), Valid: true} + var err error + switch { + case req.Name != "" && req.Plan != "": + err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ + Name: req.Name, + Plan: req.Plan, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + case req.Name != "": + err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ + Name: req.Name, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + default: + err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{ + Plan: req.Plan, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) + return + } } c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"}) @@ -839,9 +869,9 @@ func roundFloat(value float64, places int) float64 { return math.Round(value*pow) / pow } -func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, databaseID string) (DatabaseBackupConfig, error) { +func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string, db DatabaseService) (DatabaseBackupConfig, error) { rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{ - DatabaseID: databaseID, + DatabaseID: db.ID, UserID: userID, Limit: 20, }) @@ -865,17 +895,11 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, datab } } - var nextBackup *time.Time - if lastBackup != nil { - t := lastBackup.Add(24 * time.Hour) - nextBackup = &t - } - return DatabaseBackupConfig{ - Enabled: true, + Enabled: db.BackupSchedule != "", LastBackup: lastBackup, Retention: 30, - NextBackup: nextBackup, + NextBackup: db.NextBackupAt, Backups: backups, }, nil } @@ -1017,30 +1041,34 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { return DatabaseService{ ID: row.ID, - Name: row.Name, - Type: row.Type, - Status: row.Status, - Version: row.Version, - Plan: row.Plan, - Region: row.Region, - ConnectionURL: databaseNullString(row.ConnectionUrl), - CreatedAt: databaseNullTime(row.CreatedAt), - UpdatedAt: databaseNullTime(row.UpdatedAt), + Name: row.Name, + Type: row.Type, + Status: row.Status, + Version: row.Version, + Plan: row.Plan, + Region: row.Region, + BackupSchedule: databaseNullString(row.BackupSchedule), + NextBackupAt: databaseNullTimePtr(row.NextBackupAt), + ConnectionURL: databaseNullString(row.ConnectionUrl), + CreatedAt: databaseNullTime(row.CreatedAt), + UpdatedAt: databaseNullTime(row.UpdatedAt), } } func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService { return DatabaseService{ - ID: row.ID, - Name: row.Name, - Type: row.Type, - Status: row.Status, - Version: row.Version, - Plan: row.Plan, - Region: row.Region, - ConnectionURL: databaseNullString(row.ConnectionUrl), - CreatedAt: databaseNullTime(row.CreatedAt), - UpdatedAt: databaseNullTime(row.UpdatedAt), + ID: row.ID, + Name: row.Name, + Type: row.Type, + Status: row.Status, + Version: row.Version, + Plan: row.Plan, + Region: row.Region, + BackupSchedule: databaseNullString(row.BackupSchedule), + NextBackupAt: databaseNullTimePtr(row.NextBackupAt), + ConnectionURL: databaseNullString(row.ConnectionUrl), + CreatedAt: databaseNullTime(row.CreatedAt), + UpdatedAt: databaseNullTime(row.UpdatedAt), } } @@ -1051,6 +1079,14 @@ func databaseNullTime(value sql.NullTime) time.Time { return time.Time{} } +func databaseNullTimePtr(value sql.NullTime) *time.Time { + if value.Valid { + t := value.Time + return &t + } + return nil +} + func databaseNullString(value sql.NullString) string { if value.Valid { return value.String diff --git a/app/backend/internal/database/sqlcdb/databases.sql.go b/app/backend/internal/database/sqlcdb/databases.sql.go index 4a72ac6..2c969c7 100644 --- a/app/backend/internal/database/sqlcdb/databases.sql.go +++ b/app/backend/internal/database/sqlcdb/databases.sql.go @@ -150,7 +150,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a } const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one -SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at +SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE id = $1 AND user_id = $2 ` @@ -161,16 +161,18 @@ type GetDatabaseServiceByIDAndUserParams struct { } type GetDatabaseServiceByIDAndUserRow struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + ConnectionUrl sql.NullString `json:"connection_url"` + BackupSchedule sql.NullString `json:"backup_schedule"` + NextBackupAt sql.NullTime `json:"next_backup_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (GetDatabaseServiceByIDAndUserRow, error) { @@ -185,6 +187,8 @@ func (q *Queries) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetData &i.Plan, &i.Region, &i.ConnectionUrl, + &i.BackupSchedule, + &i.NextBackupAt, &i.CreatedAt, &i.UpdatedAt, ) @@ -238,23 +242,25 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg } const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many -SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at +SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC ` type ListDatabaseServicesByUserRow struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + ConnectionUrl sql.NullString `json:"connection_url"` + BackupSchedule sql.NullString `json:"backup_schedule"` + NextBackupAt sql.NullTime `json:"next_backup_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) { @@ -275,6 +281,8 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) &i.Plan, &i.Region, &i.ConnectionUrl, + &i.BackupSchedule, + &i.NextBackupAt, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -291,6 +299,66 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string) return items, nil } +const listDueDatabaseBackups = `-- name: ListDueDatabaseBackups :many +SELECT id, backup_schedule +FROM database_services +WHERE backup_schedule IS NOT NULL AND backup_schedule <> '' + AND next_backup_at IS NOT NULL AND next_backup_at <= NOW() +` + +type ListDueDatabaseBackupsRow struct { + ID string `json:"id"` + BackupSchedule sql.NullString `json:"backup_schedule"` +} + +func (q *Queries) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) { + rows, err := q.db.QueryContext(ctx, listDueDatabaseBackups) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListDueDatabaseBackupsRow{} + for rows.Next() { + var i ListDueDatabaseBackupsRow + if err := rows.Scan(&i.ID, &i.BackupSchedule); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDatabaseBackupScheduleByIDAndUser = `-- name: SetDatabaseBackupScheduleByIDAndUser :exec +UPDATE database_services +SET backup_schedule = $1, next_backup_at = $2, updated_at = $3 +WHERE id = $4 AND user_id = $5 +` + +type SetDatabaseBackupScheduleByIDAndUserParams struct { + BackupSchedule sql.NullString `json:"backup_schedule"` + NextBackupAt sql.NullTime `json:"next_backup_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` +} + +func (q *Queries) SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error { + _, err := q.db.ExecContext(ctx, setDatabaseBackupScheduleByIDAndUser, + arg.BackupSchedule, + arg.NextBackupAt, + arg.UpdatedAt, + arg.ID, + arg.UserID, + ) + return err +} + const setDatabaseBackupStatusByID = `-- name: SetDatabaseBackupStatusByID :exec UPDATE database_backups SET status = $1, size = $2, completed_at = $3 @@ -314,6 +382,22 @@ func (q *Queries) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDataba return err } +const setDatabaseNextBackupAt = `-- name: SetDatabaseNextBackupAt :exec +UPDATE database_services +SET next_backup_at = $1 +WHERE id = $2 +` + +type SetDatabaseNextBackupAtParams struct { + NextBackupAt sql.NullTime `json:"next_backup_at"` + ID string `json:"id"` +} + +func (q *Queries) SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error { + _, err := q.db.ExecContext(ctx, setDatabaseNextBackupAt, arg.NextBackupAt, arg.ID) + return err +} + const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec UPDATE database_services SET status = $1, connection_url = $2, updated_at = $3 diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index b310d60..269963e 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -80,17 +80,19 @@ type DatabaseBackup struct { } type DatabaseService struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - Version string `json:"version"` - Plan string `json:"plan"` - Region string `json:"region"` - ConnectionUrl sql.NullString `json:"connection_url"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + Version string `json:"version"` + Plan string `json:"plan"` + Region string `json:"region"` + ConnectionUrl sql.NullString `json:"connection_url"` + BackupSchedule sql.NullString `json:"backup_schedule"` + NextBackupAt sql.NullTime `json:"next_backup_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` } type Deployment struct { diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index f33072e..3e5b83e 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -49,12 +49,15 @@ type Querier interface { ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) + ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) + SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error + SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error diff --git a/app/backend/migrations_goose/20260919000000_database_backup_schedule.sql b/app/backend/migrations_goose/20260919000000_database_backup_schedule.sql new file mode 100644 index 0000000..fa85be5 --- /dev/null +++ b/app/backend/migrations_goose/20260919000000_database_backup_schedule.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE public.database_services + ADD COLUMN IF NOT EXISTS backup_schedule character varying(100), + ADD COLUMN IF NOT EXISTS next_backup_at timestamp with time zone; + +-- +goose Down +ALTER TABLE public.database_services + DROP COLUMN IF EXISTS backup_schedule, + DROP COLUMN IF EXISTS next_backup_at; diff --git a/app/backend/sqlc/queries/databases.sql b/app/backend/sqlc/queries/databases.sql index 72b49ca..a5a6f21 100644 --- a/app/backend/sqlc/queries/databases.sql +++ b/app/backend/sqlc/queries/databases.sql @@ -1,11 +1,11 @@ -- name: ListDatabaseServicesByUser :many -SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at +SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE user_id = $1 ORDER BY created_at DESC; -- name: GetDatabaseServiceByIDAndUser :one -SELECT id, name, type, status, version, plan, region, connection_url, created_at, updated_at +SELECT id, name, type, status, version, plan, region, connection_url, backup_schedule, next_backup_at, created_at, updated_at FROM database_services WHERE id = $1 AND user_id = $2; @@ -79,3 +79,19 @@ VALUES ($1, $2, $3, $4, $5, $6); UPDATE database_backups SET status = $1, size = $2, completed_at = $3 WHERE id = $4; + +-- name: SetDatabaseBackupScheduleByIDAndUser :exec +UPDATE database_services +SET backup_schedule = $1, next_backup_at = $2, updated_at = $3 +WHERE id = $4 AND user_id = $5; + +-- name: ListDueDatabaseBackups :many +SELECT id, backup_schedule +FROM database_services +WHERE backup_schedule IS NOT NULL AND backup_schedule <> '' + AND next_backup_at IS NOT NULL AND next_backup_at <= NOW(); + +-- name: SetDatabaseNextBackupAt :exec +UPDATE database_services +SET next_backup_at = $1 +WHERE id = $2; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index a36d361..76080c2 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -96,6 +96,8 @@ CREATE TABLE database_services ( plan VARCHAR(50) NOT NULL, region VARCHAR(50) NOT NULL, connection_url TEXT, + backup_schedule VARCHAR(100), + next_backup_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index a9bd14d..76c7f6b 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -20,6 +20,7 @@ import { revokeAgentToken, listDatabases, databaseAction, + updateDatabaseBackupSchedule, createDatabaseBackup, restoreDatabaseBackup, listServicesByProject, @@ -1680,6 +1681,11 @@ export function DatabasesPage() { {backupMutation.isPending ? 'Starting…' : 'New backup'} + {backups.length === 0 ? (

No backups yet.

) : ( @@ -1827,3 +1833,55 @@ function BindDatabasePanel({ ); } + +function BackupScheduleRow({ + databaseId, + schedule, + nextBackupAt, +}: { + databaseId: string; + schedule: string; + nextBackupAt?: string; +}) { + const queryClient = useQueryClient(); + const [value, setValue] = useState(schedule); + const mutation = useMutation({ + mutationFn: (expr: string) => updateDatabaseBackupSchedule(databaseId, expr), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['databases'] }), + }); + + return ( +
+ setValue(e.target.value)} + placeholder="cron schedule, e.g. 0 3 * * *" + className="w-44 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-2 py-1.5 text-xs mono text-[var(--text-primary)]" + /> + + {schedule && ( + + )} + + {schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'} + + {mutation.isError && ( + + {mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'} + + )} +
+ ); +} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 0dd9ebf..b8ac4f6 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -7059,6 +7059,13 @@ export interface components { plan?: "hobby" | "starter" | "standard" | "business"; /** @description Provisioning region */ region?: string; + /** @description Cron expression for automatic backups, empty when unset */ + backup_schedule?: string; + /** + * Format: date-time + * @description Next scheduled backup run + */ + next_backup_at?: string; metrics?: components["schemas"]["DatabaseMetrics"]; backups?: components["schemas"]["DatabaseBackupConfig"]; settings?: components["schemas"]["DatabaseSettings"]; @@ -7097,6 +7104,8 @@ export interface components { * @enum {string} */ plan?: "hobby" | "starter" | "standard" | "business"; + /** @description Cron expression for automatic backups; empty string clears the schedule */ + backup_schedule?: string | null; }; DatabaseActionRequest: { /** diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index c9df94c..19c9603 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1295,6 +1295,13 @@ export async function databaseAction(id: string, action: 'start' | 'stop' | 'res }); } +export async function updateDatabaseBackupSchedule(id: string, schedule: string): Promise { + await requestJson(`/databases/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ backup_schedule: schedule }), + }); +} + export async function createDatabaseBackup(id: string): Promise { await requestJson(`/databases/${encodeURIComponent(id)}/backup`, { method: 'POST', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 28dd7ba..ae10ff5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4778,6 +4778,13 @@ components: region: type: string description: Provisioning region + backup_schedule: + type: string + description: Cron expression for automatic backups, empty when unset + next_backup_at: + type: string + format: date-time + description: Next scheduled backup run metrics: $ref: '#/components/schemas/DatabaseMetrics' backups: @@ -4826,6 +4833,10 @@ components: type: string enum: [hobby, starter, standard, business] description: Updated database plan + backup_schedule: + type: string + nullable: true + description: Cron expression for automatic backups; empty string clears the schedule DatabaseActionRequest: type: object