feat(backup): take Tinyforge DB snapshot before every deploy

Adds an opt-in "auto_backup_before_deploy" setting that triggers a
"pre-deploy" backup at the start of every project deploy via the deploy
pipeline (covers both the async HTTP path and the sync poller/webhook
path). Failures are logged to the deploy log but do not abort — missing
a backup is preferable to refusing to ship a fix.

- store: settings.auto_backup_before_deploy column + scan/update wiring
- backup: accept "pre-deploy" as a valid backup_type
- deployer: small PreDeployBackuper interface, hooked into runDeploy
  right after settings load and before any state-mutating work
- api: settings request/response surface the new flag
- web: ToggleSwitch on the backup settings page; "Pre-deploy" badge
  variant in the backup list (badge-warning so it stands out)
- i18n: en/ru strings for the toggle, help text, and badge label
This commit is contained in:
2026-05-07 02:14:26 +03:00
parent 0405ecd9ce
commit 8b886ddf2b
11 changed files with 95 additions and 13 deletions
+13 -8
View File
@@ -43,11 +43,12 @@ type settingsRequest struct {
TraefikCertResolver *string `json:"traefik_cert_resolver,omitempty"`
TraefikNetwork *string `json:"traefik_network,omitempty"`
TraefikAPIURL *string `json:"traefik_api_url,omitempty"`
BackupEnabled *bool `json:"backup_enabled,omitempty"`
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
StatsIntervalSeconds *int `json:"stats_interval_seconds,omitempty"`
StatsRetentionHours *int `json:"stats_retention_hours,omitempty"`
BackupEnabled *bool `json:"backup_enabled,omitempty"`
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
AutoBackupBeforeDeploy *bool `json:"auto_backup_before_deploy,omitempty"`
StatsIntervalSeconds *int `json:"stats_interval_seconds,omitempty"`
StatsRetentionHours *int `json:"stats_retention_hours,omitempty"`
}
// getSettings handles GET /api/settings.
@@ -86,9 +87,10 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"traefik_cert_resolver": settings.TraefikCertResolver,
"traefik_network": settings.TraefikNetwork,
"traefik_api_url": settings.TraefikAPIURL,
"backup_enabled": settings.BackupEnabled,
"backup_interval_hours": settings.BackupIntervalHours,
"backup_retention_count": settings.BackupRetentionCount,
"backup_enabled": settings.BackupEnabled,
"backup_interval_hours": settings.BackupIntervalHours,
"backup_retention_count": settings.BackupRetentionCount,
"auto_backup_before_deploy": settings.AutoBackupBeforeDeploy,
"stats_interval_seconds": settings.StatsIntervalSeconds,
"stats_retention_hours": settings.StatsRetentionHours,
"updated_at": settings.UpdatedAt,
@@ -243,6 +245,9 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
}
updated.BackupRetentionCount = *req.BackupRetentionCount
}
if req.AutoBackupBeforeDeploy != nil {
updated.AutoBackupBeforeDeploy = *req.AutoBackupBeforeDeploy
}
if req.StatsIntervalSeconds != nil {
v := *req.StatsIntervalSeconds
if v != 0 && (v < 5 || v > 300) {
+1 -1
View File
@@ -42,7 +42,7 @@ func (e *Engine) BackupDir() string {
func (e *Engine) CreateBackup(backupType string) (store.Backup, error) {
// Validate backup type to prevent path traversal via filename.
switch backupType {
case "manual", "auto", "pre-restore":
case "manual", "auto", "pre-restore", "pre-deploy":
// valid
default:
return store.Backup{}, fmt.Errorf("invalid backup type: %q", backupType)
+36
View File
@@ -32,6 +32,7 @@ type Deployer struct {
health *health.Checker
notifier *notify.Notifier
eventBus EventPublisher
backuper PreDeployBackuper // optional; nil disables pre-deploy backups
encKey [32]byte
dnsMu sync.RWMutex
dns dns.Provider // nil when wildcard DNS is active
@@ -46,6 +47,13 @@ type EventPublisher interface {
Publish(evt events.Event)
}
// PreDeployBackuper takes a "pre-deploy" Tinyforge DB snapshot before any
// deploy starts when the corresponding setting is enabled. Kept as a small
// interface so the deployer does not import internal/backup.
type PreDeployBackuper interface {
CreateBackup(backupType string) (store.Backup, error)
}
// New creates a new Deployer with all required dependencies.
func New(
dockerClient *docker.Client,
@@ -72,6 +80,30 @@ func (d *Deployer) SetProxyProvider(provider proxy.Provider) {
d.proxy = provider
}
// SetPreDeployBackuper wires the backup engine in after construction so the
// deployer can take a Tinyforge DB snapshot when the
// auto_backup_before_deploy setting is enabled. Pass nil to disable.
func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) {
d.backuper = b
}
// maybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
// the setting is enabled. Failures are logged but do not abort the deploy:
// missing a backup is preferable to refusing to ship a fix.
func (d *Deployer) maybeBackupBeforeDeploy(deployID string, settings store.Settings) {
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
return
}
backup, err := d.backuper.CreateBackup("pre-deploy")
if err != nil {
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup failed: %v", err), "warn")
return
}
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup created: %s", backup.Filename), "info")
}
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
// Pass nil to disable DNS management (wildcard DNS mode).
func (d *Deployer) SetDNSProvider(provider dns.Provider) {
@@ -160,6 +192,10 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
)
d.logDeploy(deployID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info")
// Take a pre-deploy DB snapshot if the operator opted in. Runs before
// any state-mutating work so a corrupted deploy is recoverable.
d.maybeBackupBeforeDeploy(deployID, settings)
// Enforce max_instances before deploying.
if err := d.enforceMaxInstances(ctx, stage, deployID, settings); err != nil {
d.logDeploy(deployID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error")
+4
View File
@@ -82,6 +82,10 @@ type Settings struct {
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
// AutoBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB backup
// at the start of every project deploy. Independent of BackupEnabled
// (which governs the periodic auto-backup cron).
AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"`
+10 -1
View File
@@ -7,7 +7,7 @@ import (
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
var wildcardDNS, npmRemote, backupEnabled int
var wildcardDNS, npmRemote, backupEnabled, autoBackupBeforeDeploy int
err := s.db.QueryRow(
`SELECT domain, server_ip, public_ip, network, subdomain_pattern, notification_url,
notification_secret,
@@ -19,6 +19,7 @@ func (s *Store) GetSettings() (Settings, error) {
traefik_entrypoint, traefik_cert_resolver, traefik_network, traefik_api_url,
image_prune_threshold_mb,
backup_enabled, backup_interval_hours, backup_retention_count,
auto_backup_before_deploy,
stats_interval_seconds, stats_retention_hours,
updated_at
FROM settings WHERE id = 1`,
@@ -32,6 +33,7 @@ func (s *Store) GetSettings() (Settings, error) {
&st.TraefikEntrypoint, &st.TraefikCertResolver, &st.TraefikNetwork, &st.TraefikAPIURL,
&st.ImagePruneThresholdMB,
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
&autoBackupBeforeDeploy,
&st.StatsIntervalSeconds, &st.StatsRetentionHours,
&st.UpdatedAt)
if err != nil {
@@ -40,6 +42,7 @@ func (s *Store) GetSettings() (Settings, error) {
st.WildcardDNS = wildcardDNS != 0
st.NpmRemote = npmRemote != 0
st.BackupEnabled = backupEnabled != 0
st.AutoBackupBeforeDeploy = autoBackupBeforeDeploy != 0
return st, nil
}
@@ -58,6 +61,10 @@ func (s *Store) UpdateSettings(st Settings) error {
if st.BackupEnabled {
backupEnabled = 1
}
autoBackupBeforeDeploy := 0
if st.AutoBackupBeforeDeploy {
autoBackupBeforeDeploy = 1
}
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, public_ip=?, network=?, subdomain_pattern=?, notification_url=?,
@@ -70,6 +77,7 @@ func (s *Store) UpdateSettings(st Settings) error {
traefik_entrypoint=?, traefik_cert_resolver=?, traefik_network=?, traefik_api_url=?,
image_prune_threshold_mb=?,
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
auto_backup_before_deploy=?,
stats_interval_seconds=?, stats_retention_hours=?,
updated_at=?
WHERE id = 1`,
@@ -83,6 +91,7 @@ func (s *Store) UpdateSettings(st Settings) error {
st.TraefikEntrypoint, st.TraefikCertResolver, st.TraefikNetwork, st.TraefikAPIURL,
st.ImagePruneThresholdMB,
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
autoBackupBeforeDeploy,
st.StatsIntervalSeconds, st.StatsRetentionHours,
st.UpdatedAt,
)
+4
View File
@@ -147,6 +147,10 @@ func (s *Store) runMigrations() error {
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
// Auto-backup before deploy (2026-05-07). When enabled, the deployer
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
// so a corrupted deploy is recoverable without data loss.
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
}
// Additive stack tables (2026-04-16). Created here rather than in the