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
This commit is contained in:
2025-11-11 21:38:40 +00:00
parent b926bb7806
commit 661fd7e671
3 changed files with 238 additions and 36 deletions

View File

@@ -337,3 +337,85 @@ func (s *Safety) checkMySQLDatabaseExists(ctx context.Context, dbName string) (b
return strings.Contains(string(output), dbName), nil 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
}

View File

@@ -24,6 +24,8 @@ type RestoreExecutionModel struct {
cleanFirst bool cleanFirst bool
createIfMissing 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 // Progress tracking
status string status string
@@ -42,7 +44,7 @@ type RestoreExecutionModel struct {
} }
// NewRestoreExecution creates a new restore execution model // 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{ return RestoreExecutionModel{
config: cfg, config: cfg,
logger: log, logger: log,
@@ -52,6 +54,8 @@ func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model
cleanFirst: cleanFirst, cleanFirst: cleanFirst,
createIfMissing: createIfMissing, createIfMissing: createIfMissing,
restoreType: restoreType, restoreType: restoreType,
cleanClusterFirst: cleanClusterFirst,
existingDBs: existingDBs,
status: "Initializing...", status: "Initializing...",
phase: "Starting", phase: "Starting",
startTime: time.Now(), startTime: time.Now(),
@@ -63,7 +67,7 @@ func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model
func (m RestoreExecutionModel) Init() tea.Cmd { func (m RestoreExecutionModel) Init() tea.Cmd {
return tea.Batch( 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(), restoreTickCmd(),
) )
} }
@@ -89,7 +93,7 @@ type restoreCompleteMsg struct {
elapsed time.Duration 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 { return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel() defer cancel()
@@ -107,13 +111,41 @@ func executeRestoreWithTUIProgress(cfg *config.Config, log logger.Logger, archiv
} }
defer dbClient.Close() 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) engine := restore.NewSilent(cfg, log, dbClient)
// Set up progress callback (but it won't work in goroutine - progress is already sent via logs) // 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 // 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 var restoreErr error
if restoreType == "restore-cluster" { if restoreType == "restore-cluster" {
restoreErr = engine.RestoreCluster(ctx, archive.Path) 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) result := fmt.Sprintf("Successfully restored from %s", archive.Name)
if restoreType == "restore-single" { if restoreType == "restore-single" {
result = fmt.Sprintf("Successfully restored '%s' from %s", targetDB, archive.Name) 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{ return restoreCompleteMsg{

View File

@@ -51,6 +51,9 @@ type RestorePreviewModel struct {
targetDB string targetDB string
cleanFirst bool cleanFirst bool
createIfMissing 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 safetyChecks []SafetyCheck
checking bool checking bool
canProceed bool canProceed bool
@@ -91,6 +94,8 @@ func (m RestorePreviewModel) Init() tea.Cmd {
type safetyCheckCompleteMsg struct { type safetyCheckCompleteMsg struct {
checks []SafetyCheck checks []SafetyCheck
canProceed bool canProceed bool
existingDBCount int
existingDBs []string
} }
func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd { 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) checks = append(checks, check)
// 4. Target database check (skip for cluster restores) // 4. Target database check (skip for cluster restores)
existingDBCount := 0
existingDBs := []string{}
if !archive.Format.IsClusterBackup() { if !archive.Format.IsClusterBackup() {
check = SafetyCheck{Name: "Target database", Status: "checking", Critical: false} check = SafetyCheck{Name: "Target database", Status: "checking", Critical: false}
exists, err := safety.CheckDatabaseExists(ctx, targetDB) exists, err := safety.CheckDatabaseExists(ctx, targetDB)
@@ -162,13 +170,35 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
} }
checks = append(checks, check) checks = append(checks, check)
} else { } else {
// For cluster restores, just show a general message // For cluster restores, detect existing user databases
check = SafetyCheck{Name: "Cluster restore", Status: "passed", Critical: false} check = SafetyCheck{Name: "Existing databases", Status: "checking", Critical: false}
check.Message = "Will restore all databases from cluster backup"
// 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) 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.checking = false
m.safetyChecks = msg.checks m.safetyChecks = msg.checks
m.canProceed = msg.canProceed m.canProceed = msg.canProceed
m.existingDBCount = msg.existingDBCount
m.existingDBs = msg.existingDBs
return m, nil return m, nil
case tea.KeyMsg: 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) m.message = fmt.Sprintf("Clean-first: %v", m.cleanFirst)
case "c": case "c":
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 // Toggle create if missing
m.createIfMissing = !m.createIfMissing m.createIfMissing = !m.createIfMissing
m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing) m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing)
}
case "enter", " ": case "enter", " ":
if m.checking { if m.checking {
@@ -207,7 +249,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// Proceed to restore execution // 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() return exec, exec.Init()
} }
} }
@@ -238,7 +280,7 @@ func (m RestorePreviewModel) View() string {
} }
s.WriteString("\n") s.WriteString("\n")
// Target Information (only for single restore) // Target Information
if m.mode == "restore-single" { if m.mode == "restore-single" {
s.WriteString(archiveHeaderStyle.Render("🎯 Target Information")) s.WriteString(archiveHeaderStyle.Render("🎯 Target Information"))
s.WriteString("\n") 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(fmt.Sprintf(" Create If Missing: %s %v\n", createIcon, m.createIfMissing))
s.WriteString("\n") 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 // 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(infoStyle.Render(" All existing data in target database will be dropped!"))
s.WriteString("\n\n") 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 // Message
if m.message != "" { if m.message != "" {
@@ -318,6 +398,12 @@ func (m RestorePreviewModel) View() string {
s.WriteString("\n") s.WriteString("\n")
if m.mode == "restore-single" { if m.mode == "restore-single" {
s.WriteString(infoStyle.Render("⌨️ t: Toggle clean-first | c: Toggle create | Enter: Proceed | Esc: Cancel")) 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 { } else {
s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel")) s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel"))
} }