From 661fd7e671204af8112e26248f2f0b4e5b444641 Mon Sep 17 00:00:00 2001 From: Renz Date: Tue, 11 Nov 2025 21:38:40 +0000 Subject: [PATCH] Add Option C: Smart cluster cleanup before restore (TUI) - Auto-detects existing user databases before cluster restore - Shows count and list (first 5) in preview screen - Toggle option 'c' to enable cluster cleanup - Drops all user databases before restore when enabled - Works for PostgreSQL, MySQL, MariaDB - Safety warning with database count - Implements practical disaster recovery workflow --- internal/restore/safety.go | 82 ++++++++++++++++++++++++ internal/tui/restore_exec.go | 84 +++++++++++++++++-------- internal/tui/restore_preview.go | 108 ++++++++++++++++++++++++++++---- 3 files changed, 238 insertions(+), 36 deletions(-) diff --git a/internal/restore/safety.go b/internal/restore/safety.go index 1359e6a..d478aa5 100644 --- a/internal/restore/safety.go +++ b/internal/restore/safety.go @@ -337,3 +337,85 @@ func (s *Safety) checkMySQLDatabaseExists(ctx context.Context, dbName string) (b return strings.Contains(string(output), dbName), nil } + +// ListUserDatabases returns list of user databases (excludes templates and system DBs) +func (s *Safety) ListUserDatabases(ctx context.Context) ([]string, error) { + if s.cfg.DatabaseType == "postgres" { + return s.listPostgresUserDatabases(ctx) + } else if s.cfg.DatabaseType == "mysql" || s.cfg.DatabaseType == "mariadb" { + return s.listMySQLUserDatabases(ctx) + } + + return nil, fmt.Errorf("unsupported database type: %s", s.cfg.DatabaseType) +} + +// listPostgresUserDatabases lists PostgreSQL user databases +func (s *Safety) listPostgresUserDatabases(ctx context.Context) ([]string, error) { + // Query to get non-template databases excluding 'postgres' system DB + query := "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres' ORDER BY datname" + + cmd := exec.CommandContext(ctx, + "psql", + "-h", s.cfg.Host, + "-p", fmt.Sprintf("%d", s.cfg.Port), + "-U", s.cfg.User, + "-d", "postgres", + "-tA", // Tuples only, unaligned + "-c", query, + ) + + cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", s.cfg.Password)) + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list databases: %w", err) + } + + // Parse output + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + databases := []string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + databases = append(databases, line) + } + } + + return databases, nil +} + +// listMySQLUserDatabases lists MySQL/MariaDB user databases +func (s *Safety) listMySQLUserDatabases(ctx context.Context) ([]string, error) { + // Exclude system databases + query := "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema', 'mysql', 'performance_schema', 'sys') ORDER BY SCHEMA_NAME" + + cmd := exec.CommandContext(ctx, + "mysql", + "-h", s.cfg.Host, + "-P", fmt.Sprintf("%d", s.cfg.Port), + "-u", s.cfg.User, + "-N", // Skip column names + "-e", query, + ) + + if s.cfg.Password != "" { + cmd.Env = append(os.Environ(), fmt.Sprintf("MYSQL_PWD=%s", s.cfg.Password)) + } + + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to list databases: %w", err) + } + + // Parse output + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + databases := []string{} + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + databases = append(databases, line) + } + } + + return databases, nil +} diff --git a/internal/tui/restore_exec.go b/internal/tui/restore_exec.go index f0276a7..7dc5587 100644 --- a/internal/tui/restore_exec.go +++ b/internal/tui/restore_exec.go @@ -16,14 +16,16 @@ import ( // RestoreExecutionModel handles restore execution with progress type RestoreExecutionModel struct { - config *config.Config - logger logger.Logger - parent tea.Model - archive ArchiveInfo - targetDB string - cleanFirst bool + config *config.Config + logger logger.Logger + parent tea.Model + archive ArchiveInfo + targetDB string + cleanFirst bool createIfMissing bool - restoreType string + restoreType string + cleanClusterFirst bool // Drop all user databases before cluster restore + existingDBs []string // List of databases to drop // Progress tracking status string @@ -42,28 +44,30 @@ type RestoreExecutionModel struct { } // NewRestoreExecution creates a new restore execution model -func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string) RestoreExecutionModel { +func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string) RestoreExecutionModel { return RestoreExecutionModel{ - config: cfg, - logger: log, - parent: parent, - archive: archive, - targetDB: targetDB, - cleanFirst: cleanFirst, + config: cfg, + logger: log, + parent: parent, + archive: archive, + targetDB: targetDB, + cleanFirst: cleanFirst, createIfMissing: createIfMissing, - restoreType: restoreType, - status: "Initializing...", - phase: "Starting", - startTime: time.Now(), - details: []string{}, - spinnerFrames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - spinnerFrame: 0, + restoreType: restoreType, + cleanClusterFirst: cleanClusterFirst, + existingDBs: existingDBs, + status: "Initializing...", + phase: "Starting", + startTime: time.Now(), + details: []string{}, + spinnerFrames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, + spinnerFrame: 0, } } func (m RestoreExecutionModel) Init() tea.Cmd { return tea.Batch( - executeRestoreWithTUIProgress(m.config, m.logger, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.restoreType), + executeRestoreWithTUIProgress(m.config, m.logger, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.restoreType, m.cleanClusterFirst, m.existingDBs), restoreTickCmd(), ) } @@ -89,7 +93,7 @@ type restoreCompleteMsg struct { elapsed time.Duration } -func executeRestoreWithTUIProgress(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string) tea.Cmd { +func executeRestoreWithTUIProgress(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() @@ -107,13 +111,41 @@ func executeRestoreWithTUIProgress(cfg *config.Config, log logger.Logger, archiv } defer dbClient.Close() - // Create restore engine with silent progress (no stdout interference with TUI) + // STEP 1: Clean cluster if requested (drop all existing user databases) + if restoreType == "restore-cluster" && cleanClusterFirst && len(existingDBs) > 0 { + log.Info("Dropping existing user databases before cluster restore", "count", len(existingDBs)) + + // Connect to database for cleanup + if err := dbClient.Connect(ctx); err != nil { + return restoreCompleteMsg{ + result: "", + err: fmt.Errorf("failed to connect for cleanup: %w", err), + elapsed: time.Since(start), + } + } + + // Drop each database + droppedCount := 0 + for _, dbName := range existingDBs { + if err := dbClient.DropDatabase(ctx, dbName); err != nil { + log.Warn("Failed to drop database", "name", dbName, "error", err) + // Continue with other databases + } else { + droppedCount++ + log.Info("Dropped database", "name", dbName) + } + } + + log.Info("Cluster cleanup completed", "dropped", droppedCount, "total", len(existingDBs)) + } + + // STEP 2: Create restore engine with silent progress (no stdout interference with TUI) engine := restore.NewSilent(cfg, log, dbClient) // Set up progress callback (but it won't work in goroutine - progress is already sent via logs) // The TUI will just use spinner animation to show activity - // Execute restore based on type + // STEP 3: Execute restore based on type var restoreErr error if restoreType == "restore-cluster" { restoreErr = engine.RestoreCluster(ctx, archive.Path) @@ -132,6 +164,8 @@ func executeRestoreWithTUIProgress(cfg *config.Config, log logger.Logger, archiv result := fmt.Sprintf("Successfully restored from %s", archive.Name) if restoreType == "restore-single" { result = fmt.Sprintf("Successfully restored '%s' from %s", targetDB, archive.Name) + } else if restoreType == "restore-cluster" && cleanClusterFirst { + result = fmt.Sprintf("Successfully restored cluster from %s (cleaned %d existing database(s) first)", archive.Name, len(existingDBs)) } return restoreCompleteMsg{ diff --git a/internal/tui/restore_preview.go b/internal/tui/restore_preview.go index 362f4af..1fa84cf 100644 --- a/internal/tui/restore_preview.go +++ b/internal/tui/restore_preview.go @@ -51,6 +51,9 @@ type RestorePreviewModel struct { targetDB string cleanFirst bool createIfMissing bool + cleanClusterFirst bool // For cluster restore: drop all user databases first + existingDBCount int // Number of existing user databases + existingDBs []string // List of existing user databases safetyChecks []SafetyCheck checking bool canProceed bool @@ -89,8 +92,10 @@ func (m RestorePreviewModel) Init() tea.Cmd { } type safetyCheckCompleteMsg struct { - checks []SafetyCheck - canProceed bool + checks []SafetyCheck + canProceed bool + existingDBCount int + existingDBs []string } func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd { @@ -147,6 +152,9 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, checks = append(checks, check) // 4. Target database check (skip for cluster restores) + existingDBCount := 0 + existingDBs := []string{} + if !archive.Format.IsClusterBackup() { check = SafetyCheck{Name: "Target database", Status: "checking", Critical: false} exists, err := safety.CheckDatabaseExists(ctx, targetDB) @@ -162,13 +170,35 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, } checks = append(checks, check) } else { - // For cluster restores, just show a general message - check = SafetyCheck{Name: "Cluster restore", Status: "passed", Critical: false} - check.Message = "Will restore all databases from cluster backup" + // For cluster restores, detect existing user databases + check = SafetyCheck{Name: "Existing databases", Status: "checking", Critical: false} + + // Get list of existing user databases (exclude templates and system DBs) + dbList, err := safety.ListUserDatabases(ctx) + if err != nil { + check.Status = "warning" + check.Message = fmt.Sprintf("Cannot list databases: %v", err) + } else { + existingDBCount = len(dbList) + existingDBs = dbList + + if existingDBCount > 0 { + check.Status = "warning" + check.Message = fmt.Sprintf("Found %d existing user database(s) - can be cleaned before restore", existingDBCount) + } else { + check.Status = "passed" + check.Message = "No existing user databases - clean slate" + } + } checks = append(checks, check) } - return safetyCheckCompleteMsg{checks: checks, canProceed: canProceed} + return safetyCheckCompleteMsg{ + checks: checks, + canProceed: canProceed, + existingDBCount: existingDBCount, + existingDBs: existingDBs, + } } } @@ -178,6 +208,8 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.checking = false m.safetyChecks = msg.checks m.canProceed = msg.canProceed + m.existingDBCount = msg.existingDBCount + m.existingDBs = msg.existingDBs return m, nil case tea.KeyMsg: @@ -191,9 +223,19 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.message = fmt.Sprintf("Clean-first: %v", m.cleanFirst) case "c": - // Toggle create if missing - m.createIfMissing = !m.createIfMissing - m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing) + if m.mode == "restore-cluster" { + // Toggle cluster cleanup + m.cleanClusterFirst = !m.cleanClusterFirst + if m.cleanClusterFirst { + m.message = checkWarningStyle.Render(fmt.Sprintf("⚠️ Will drop %d existing database(s) before restore", m.existingDBCount)) + } else { + m.message = fmt.Sprintf("Clean cluster first: disabled") + } + } else { + // Toggle create if missing + m.createIfMissing = !m.createIfMissing + m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing) + } case "enter", " ": if m.checking { @@ -207,7 +249,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Proceed to restore execution - exec := NewRestoreExecution(m.config, m.logger, m.parent, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.mode) + exec := NewRestoreExecution(m.config, m.logger, m.parent, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.mode, m.cleanClusterFirst, m.existingDBs) return exec, exec.Init() } } @@ -238,7 +280,7 @@ func (m RestorePreviewModel) View() string { } s.WriteString("\n") - // Target Information (only for single restore) + // Target Information if m.mode == "restore-single" { s.WriteString(archiveHeaderStyle.Render("🎯 Target Information")) s.WriteString("\n") @@ -257,6 +299,36 @@ func (m RestorePreviewModel) View() string { } s.WriteString(fmt.Sprintf(" Create If Missing: %s %v\n", createIcon, m.createIfMissing)) s.WriteString("\n") + } else if m.mode == "restore-cluster" { + s.WriteString(archiveHeaderStyle.Render("🎯 Cluster Restore Options")) + s.WriteString("\n") + s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) + + if m.existingDBCount > 0 { + s.WriteString(fmt.Sprintf(" Existing Databases: %d found\n", m.existingDBCount)) + + // Show first few database names + maxShow := 5 + for i, db := range m.existingDBs { + if i >= maxShow { + remaining := len(m.existingDBs) - maxShow + s.WriteString(fmt.Sprintf(" ... and %d more\n", remaining)) + break + } + s.WriteString(fmt.Sprintf(" - %s\n", db)) + } + + cleanIcon := "✗" + cleanStyle := infoStyle + if m.cleanClusterFirst { + cleanIcon = "✓" + cleanStyle = checkWarningStyle + } + s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s %v (press 'c' to toggle)\n", cleanIcon, m.cleanClusterFirst))) + } else { + s.WriteString(" Existing Databases: None (clean slate)\n") + } + s.WriteString("\n") } // Safety Checks @@ -303,6 +375,14 @@ func (m RestorePreviewModel) View() string { s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!")) s.WriteString("\n\n") } + if m.cleanClusterFirst && m.existingDBCount > 0 { + s.WriteString(checkWarningStyle.Render("🔥 WARNING: Cluster cleanup enabled")) + s.WriteString("\n") + s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount))) + s.WriteString("\n") + s.WriteString(infoStyle.Render(" This ensures a clean disaster recovery scenario")) + s.WriteString("\n\n") + } // Message if m.message != "" { @@ -318,6 +398,12 @@ func (m RestorePreviewModel) View() string { s.WriteString("\n") if m.mode == "restore-single" { s.WriteString(infoStyle.Render("⌨️ t: Toggle clean-first | c: Toggle create | Enter: Proceed | Esc: Cancel")) + } else if m.mode == "restore-cluster" { + if m.existingDBCount > 0 { + s.WriteString(infoStyle.Render("⌨️ c: Toggle cleanup | Enter: Proceed | Esc: Cancel")) + } else { + s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel")) + } } else { s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel")) }