mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
aeb91b03e9
commit
08d92fb10f
13 files changed
+387
-99
No files matched your search
+6
-3
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
revokeAgentToken,
|
||||
listDatabases,
|
||||
databaseAction,
|
||||
updateDatabaseBackupSchedule,
|
||||
createDatabaseBackup,
|
||||
restoreDatabaseBackup,
|
||||
listServicesByProject,
|
||||
@@ -1680,6 +1681,11 @@ export function DatabasesPage() {
|
||||
{backupMutation.isPending ? 'Starting…' : 'New backup'}
|
||||
</button>
|
||||
</div>
|
||||
<BackupScheduleRow
|
||||
databaseId={db.id ?? ''}
|
||||
schedule={db.backup_schedule ?? ''}
|
||||
nextBackupAt={db.next_backup_at}
|
||||
/>
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">No backups yet.</p>
|
||||
) : (
|
||||
@@ -1827,3 +1833,55 @@ function BindDatabasePanel({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
/**
|
||||
|
||||
@@ -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> {
|
||||
await requestJson(`/databases/${encodeURIComponent(id)}/backup`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user