diff --git a/cmd/server/main.go b/cmd/server/main.go index b805bce..a328f69 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -227,6 +227,7 @@ func main() { slog.Error("create backup engine", "error", err) os.Exit(1) } + dep.SetPreDeployBackuper(backupEngine) // Clean orphaned backup files and prune on startup. if cleaned, err := backupEngine.CleanOrphans(); err != nil { diff --git a/internal/api/settings.go b/internal/api/settings.go index fef7e3b..775a2f9 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -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) { diff --git a/internal/backup/engine.go b/internal/backup/engine.go index aaa8585..61ce244 100644 --- a/internal/backup/engine.go +++ b/internal/backup/engine.go @@ -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) diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index ceb4eba..585628d 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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") diff --git a/internal/store/models.go b/internal/store/models.go index 6c581c7..acd4248 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` diff --git a/internal/store/settings.go b/internal/store/settings.go index 45413d2..2244cab 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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, ) diff --git a/internal/store/store.go b/internal/store/store.go index 6c6ffa6..f8f3a6f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 9ca2071..e685e7c 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -553,6 +553,9 @@ "restoreFailed": "Failed to restore backup", "typeManual": "Manual", "typeAuto": "Auto", + "typePreDeploy": "Pre-deploy", + "preDeploy": "Backup before every deploy", + "preDeployHelp": "Take a Tinyforge DB snapshot at the start of every project deploy. Independent of the periodic schedule above; restorable from this list under the \"Pre-deploy\" type.", "save": "Save", "saving": "Saving...", "saved": "Backup settings saved", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 04a7644..5399fec 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -553,6 +553,9 @@ "restoreFailed": "Не удалось восстановить резервную копию", "typeManual": "Ручная", "typeAuto": "Авто", + "typePreDeploy": "Перед деплоем", + "preDeploy": "Снимок перед каждым деплоем", + "preDeployHelp": "Создавать снимок БД Tinyforge в начале каждого деплоя проекта. Независимо от периодического расписания выше; восстанавливается из списка ниже по типу «Перед деплоем».", "save": "Сохранить", "saving": "Сохранение...", "saved": "Настройки копирования сохранены", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 66059e8..79af4c5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -134,6 +134,7 @@ export interface Settings { backup_enabled: boolean; backup_interval_hours: number; backup_retention_count: number; + auto_backup_before_deploy: boolean; stats_interval_seconds: number; stats_retention_hours: number; updated_at: string; diff --git a/web/src/routes/settings/backup/+page.svelte b/web/src/routes/settings/backup/+page.svelte index 2faed5e..1d387e8 100644 --- a/web/src/routes/settings/backup/+page.svelte +++ b/web/src/routes/settings/backup/+page.svelte @@ -19,6 +19,7 @@ let backupEnabled = $state(false); let backupIntervalHours = $state('24'); let backupRetentionCount = $state('10'); + let autoBackupBeforeDeploy = $state(false); let backups = $state([]); let confirmDeleteId = $state(''); @@ -38,6 +39,7 @@ backupEnabled = settings.backup_enabled ?? false; backupIntervalHours = String(settings.backup_interval_hours ?? 24); backupRetentionCount = String(settings.backup_retention_count ?? 10); + autoBackupBeforeDeploy = settings.auto_backup_before_deploy ?? false; backups = backupList ?? []; } catch (err) { toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings'); @@ -53,7 +55,8 @@ await updateSettings({ backup_enabled: backupEnabled, backup_interval_hours: Math.max(1, parseInt(backupIntervalHours, 10) || 24), - backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10) + backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10), + auto_backup_before_deploy: autoBackupBeforeDeploy }); toasts.success($t('settingsBackup.saved')); } catch (err) { @@ -181,6 +184,15 @@ {/if} + +
+ +
+ {$t('settingsBackup.preDeploy')} +

{$t('settingsBackup.preDeployHelp')}

+
+
+