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

+6 -3
View File
@@ -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 through logs was not viable). Download action in the `/databases` backups
list. Verified e2e: byte-identical archive, 404 missing/cross-user, list. Verified e2e: byte-identical archive, 404 missing/cross-user,
401 unauthenticated. 401 unauthenticated.
- [ ] **Scheduled database backups**: backups are manual-only despite a - [x] **Scheduled database backups**: `database_services.backup_schedule`
`retention` field. Add a `schedule` column + fold due database backups (cron expr) + `next_backup_at`; `PUT /databases/:id` accepts
into the cron scheduler tick. `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 **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 "API-only" note in the spec. A request to `/g/{service}` with a valid key
+48
View File
@@ -2,8 +2,10 @@ package api
import ( import (
"containr/internal/database" "containr/internal/database"
"containr/internal/database/sqlcdb"
"containr/internal/docker" "containr/internal/docker"
"context" "context"
"database/sql"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -436,6 +438,7 @@ func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *dock
return return
case <-ticker.C: case <-ticker.C:
runDueCronJobs(db, dockerClient) 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 // findServiceContainer returns the first running container whose name matches
// the deployment naming convention containr-<serviceID>-*. // the deployment naming convention containr-<serviceID>-*.
func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) { func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) {
+97 -61
View File
@@ -47,6 +47,8 @@ type DatabaseService struct {
Version string `json:"version" db:"version"` Version string `json:"version" db:"version"`
Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business
Region string `json:"region" db:"region"` 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"` CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ConnectionURL string `json:"connection_url"` ConnectionURL string `json:"connection_url"`
@@ -102,8 +104,9 @@ type DatabaseCreateRequest struct {
// DatabaseUpdateRequest represents a request to update a database // DatabaseUpdateRequest represents a request to update a database
type DatabaseUpdateRequest struct { type DatabaseUpdateRequest struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Plan string `json:"plan,omitempty"` Plan string `json:"plan,omitempty"`
BackupSchedule *string `json:"backup_schedule,omitempty"`
} }
// DatabaseActionRequest represents a request to perform database actions // 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 = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(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 db.Backups = backupConfig
} else { } else {
db.Backups = h.generateMockBackupConfig() db.Backups = h.generateMockBackupConfig()
@@ -220,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) {
db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db = h.reconcileManagedDatabaseState(c.Request.Context(), db)
db.Metrics = h.resolveDatabaseMetrics(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 db.Backups = backupConfig
} else { } else {
db.Backups = h.generateMockBackupConfig() db.Backups = h.generateMockBackupConfig()
@@ -364,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) {
req.Name = strings.TrimSpace(req.Name) req.Name = strings.TrimSpace(req.Name)
req.Plan = strings.TrimSpace(req.Plan) 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"}) c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"})
return return
} }
now := sql.NullTime{Time: time.Now(), Valid: true} if req.BackupSchedule != nil {
var err error var schedule sql.NullString
switch { var nextBackup sql.NullTime
case req.Name != "" && req.Plan != "": if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" {
err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ next, err := calculateNextRun(expr, "UTC")
Name: req.Name, if err != nil {
Plan: req.Plan, c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"})
UpdatedAt: now, return
ID: databaseID, }
UserID: userID, schedule = sql.NullString{String: expr, Valid: true}
}) nextBackup = sql.NullTime{Time: *next, Valid: true}
case req.Name != "": }
err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{
Name: req.Name, BackupSchedule: schedule,
UpdatedAt: now, NextBackupAt: nextBackup,
ID: databaseID, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
UserID: userID, 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 backup schedule"})
return
}
} }
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) if req.Name != "" || req.Plan != "" {
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 err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"})
return
}
} }
c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"}) 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 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{ rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{
DatabaseID: databaseID, DatabaseID: db.ID,
UserID: userID, UserID: userID,
Limit: 20, 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{ return DatabaseBackupConfig{
Enabled: true, Enabled: db.BackupSchedule != "",
LastBackup: lastBackup, LastBackup: lastBackup,
Retention: 30, Retention: 30,
NextBackup: nextBackup, NextBackup: db.NextBackupAt,
Backups: backups, Backups: backups,
}, nil }, nil
} }
@@ -1017,30 +1041,34 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da
func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService {
return DatabaseService{ return DatabaseService{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Type: row.Type, Type: row.Type,
Status: row.Status, Status: row.Status,
Version: row.Version, Version: row.Version,
Plan: row.Plan, Plan: row.Plan,
Region: row.Region, Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl), BackupSchedule: databaseNullString(row.BackupSchedule),
CreatedAt: databaseNullTime(row.CreatedAt), NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
UpdatedAt: databaseNullTime(row.UpdatedAt), ConnectionURL: databaseNullString(row.ConnectionUrl),
CreatedAt: databaseNullTime(row.CreatedAt),
UpdatedAt: databaseNullTime(row.UpdatedAt),
} }
} }
func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService { func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService {
return DatabaseService{ return DatabaseService{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Type: row.Type, Type: row.Type,
Status: row.Status, Status: row.Status,
Version: row.Version, Version: row.Version,
Plan: row.Plan, Plan: row.Plan,
Region: row.Region, Region: row.Region,
ConnectionURL: databaseNullString(row.ConnectionUrl), BackupSchedule: databaseNullString(row.BackupSchedule),
CreatedAt: databaseNullTime(row.CreatedAt), NextBackupAt: databaseNullTimePtr(row.NextBackupAt),
UpdatedAt: databaseNullTime(row.UpdatedAt), 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{} 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 { func databaseNullString(value sql.NullString) string {
if value.Valid { if value.Valid {
return value.String return value.String
@@ -150,7 +150,7 @@ func (q *Queries) GetDatabaseBackupByIDAndDatabaseAndUser(ctx context.Context, a
} }
const getDatabaseServiceByIDAndUser = `-- name: GetDatabaseServiceByIDAndUser :one 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 FROM database_services
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
` `
@@ -161,16 +161,18 @@ type GetDatabaseServiceByIDAndUserParams struct {
} }
type GetDatabaseServiceByIDAndUserRow struct { type GetDatabaseServiceByIDAndUserRow struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Plan string `json:"plan"` Plan string `json:"plan"`
Region string `json:"region"` Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"` ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"` BackupSchedule sql.NullString `json:"backup_schedule"`
UpdatedAt sql.NullTime `json:"updated_at"` 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) { 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.Plan,
&i.Region, &i.Region,
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
) )
@@ -238,23 +242,25 @@ func (q *Queries) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg
} }
const listDatabaseServicesByUser = `-- name: ListDatabaseServicesByUser :many 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 FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC ORDER BY created_at DESC
` `
type ListDatabaseServicesByUserRow struct { type ListDatabaseServicesByUserRow struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Plan string `json:"plan"` Plan string `json:"plan"`
Region string `json:"region"` Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"` ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"` BackupSchedule sql.NullString `json:"backup_schedule"`
UpdatedAt sql.NullTime `json:"updated_at"` 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) { 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.Plan,
&i.Region, &i.Region,
&i.ConnectionUrl, &i.ConnectionUrl,
&i.BackupSchedule,
&i.NextBackupAt,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
); err != nil { ); err != nil {
@@ -291,6 +299,66 @@ func (q *Queries) ListDatabaseServicesByUser(ctx context.Context, userID string)
return items, nil 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 const setDatabaseBackupStatusByID = `-- name: SetDatabaseBackupStatusByID :exec
UPDATE database_backups UPDATE database_backups
SET status = $1, size = $2, completed_at = $3 SET status = $1, size = $2, completed_at = $3
@@ -314,6 +382,22 @@ func (q *Queries) SetDatabaseBackupStatusByID(ctx context.Context, arg SetDataba
return err 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 const setDatabaseServiceStatusAndConnectionByID = `-- name: SetDatabaseServiceStatusAndConnectionByID :exec
UPDATE database_services UPDATE database_services
SET status = $1, connection_url = $2, updated_at = $3 SET status = $1, connection_url = $2, updated_at = $3
+13 -11
View File
@@ -80,17 +80,19 @@ type DatabaseBackup struct {
} }
type DatabaseService struct { type DatabaseService struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
Version string `json:"version"` Version string `json:"version"`
Plan string `json:"plan"` Plan string `json:"plan"`
Region string `json:"region"` Region string `json:"region"`
ConnectionUrl sql.NullString `json:"connection_url"` ConnectionUrl sql.NullString `json:"connection_url"`
CreatedAt sql.NullTime `json:"created_at"` BackupSchedule sql.NullString `json:"backup_schedule"`
UpdatedAt sql.NullTime `json:"updated_at"` NextBackupAt sql.NullTime `json:"next_backup_at"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
} }
type Deployment struct { type Deployment struct {
@@ -49,12 +49,15 @@ type Querier interface {
ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error) ListContainersForAgent(ctx context.Context, nodeAgentID string) ([]ContainerInstance, error)
ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error)
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]ListDatabaseServicesByUserRow, error)
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error) ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error) ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
@@ -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;
+18 -2
View File
@@ -1,11 +1,11 @@
-- name: ListDatabaseServicesByUser :many -- 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 FROM database_services
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: GetDatabaseServiceByIDAndUser :one -- 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 FROM database_services
WHERE id = $1 AND user_id = $2; WHERE id = $1 AND user_id = $2;
@@ -79,3 +79,19 @@ VALUES ($1, $2, $3, $4, $5, $6);
UPDATE database_backups UPDATE database_backups
SET status = $1, size = $2, completed_at = $3 SET status = $1, size = $2, completed_at = $3
WHERE id = $4; 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;
+2
View File
@@ -96,6 +96,8 @@ CREATE TABLE database_services (
plan VARCHAR(50) NOT NULL, plan VARCHAR(50) NOT NULL,
region VARCHAR(50) NOT NULL, region VARCHAR(50) NOT NULL,
connection_url TEXT, connection_url TEXT,
backup_schedule VARCHAR(100),
next_backup_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );
@@ -20,6 +20,7 @@ import {
revokeAgentToken, revokeAgentToken,
listDatabases, listDatabases,
databaseAction, databaseAction,
updateDatabaseBackupSchedule,
createDatabaseBackup, createDatabaseBackup,
restoreDatabaseBackup, restoreDatabaseBackup,
listServicesByProject, listServicesByProject,
@@ -1680,6 +1681,11 @@ export function DatabasesPage() {
{backupMutation.isPending ? 'Starting…' : 'New backup'} {backupMutation.isPending ? 'Starting…' : 'New backup'}
</button> </button>
</div> </div>
<BackupScheduleRow
databaseId={db.id ?? ''}
schedule={db.backup_schedule ?? ''}
nextBackupAt={db.next_backup_at}
/>
{backups.length === 0 ? ( {backups.length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p> <p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
) : ( ) : (
@@ -1827,3 +1833,55 @@ function BindDatabasePanel({
</div> </div>
); );
} }
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 (
<div className="mb-3 flex flex-wrap items-center gap-2">
<input
value={value}
onChange={(e) => 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)]"
/>
<button
onClick={() => mutation.mutate(value.trim())}
disabled={mutation.isPending || value.trim() === schedule}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
{mutation.isPending ? 'Saving…' : 'Save schedule'}
</button>
{schedule && (
<button
onClick={() => { setValue(''); mutation.mutate(''); }}
disabled={mutation.isPending}
className="px-2.5 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs text-[var(--text-secondary)] hover:border-[var(--border-default)] disabled:opacity-40"
>
Disable
</button>
)}
<span className="text-xs text-[var(--text-muted)]">
{schedule ? `auto-backup ${schedule}${nextBackupAt ? ` — next ${formatRelative(nextBackupAt)}` : ''}` : 'manual only'}
</span>
{mutation.isError && (
<span className="text-xs text-[var(--error)]">
{mutation.error instanceof Error ? mutation.error.message : 'Failed to save schedule'}
</span>
)}
</div>
);
}
+9
View File
@@ -7059,6 +7059,13 @@ export interface components {
plan?: "hobby" | "starter" | "standard" | "business"; plan?: "hobby" | "starter" | "standard" | "business";
/** @description Provisioning region */ /** @description Provisioning region */
region?: string; 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"]; metrics?: components["schemas"]["DatabaseMetrics"];
backups?: components["schemas"]["DatabaseBackupConfig"]; backups?: components["schemas"]["DatabaseBackupConfig"];
settings?: components["schemas"]["DatabaseSettings"]; settings?: components["schemas"]["DatabaseSettings"];
@@ -7097,6 +7104,8 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
plan?: "hobby" | "starter" | "standard" | "business"; plan?: "hobby" | "starter" | "standard" | "business";
/** @description Cron expression for automatic backups; empty string clears the schedule */
backup_schedule?: string | null;
}; };
DatabaseActionRequest: { DatabaseActionRequest: {
/** /**
+7
View File
@@ -1295,6 +1295,13 @@ export async function databaseAction(id: string, action: 'start' | 'stop' | 'res
}); });
} }
export async function updateDatabaseBackupSchedule(id: string, schedule: string): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify({ backup_schedule: schedule }),
});
}
export async function createDatabaseBackup(id: string): Promise<void> { export async function createDatabaseBackup(id: string): Promise<void> {
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, { await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
method: 'POST', method: 'POST',
+11
View File
@@ -4778,6 +4778,13 @@ components:
region: region:
type: string type: string
description: Provisioning region 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: metrics:
$ref: '#/components/schemas/DatabaseMetrics' $ref: '#/components/schemas/DatabaseMetrics'
backups: backups:
@@ -4826,6 +4833,10 @@ components:
type: string type: string
enum: [hobby, starter, standard, business] enum: [hobby, starter, standard, business]
description: Updated database plan description: Updated database plan
backup_schedule:
type: string
nullable: true
description: Cron expression for automatic backups; empty string clears the schedule
DatabaseActionRequest: DatabaseActionRequest:
type: object type: object