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

@@ -16,14 +16,16 @@ import (
// RestoreExecutionModel handles restore execution with progress // RestoreExecutionModel handles restore execution with progress
type RestoreExecutionModel struct { type RestoreExecutionModel struct {
config *config.Config config *config.Config
logger logger.Logger logger logger.Logger
parent tea.Model parent tea.Model
archive ArchiveInfo archive ArchiveInfo
targetDB string targetDB string
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,28 +44,30 @@ 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,
parent: parent, parent: parent,
archive: archive, archive: archive,
targetDB: targetDB, targetDB: targetDB,
cleanFirst: cleanFirst, cleanFirst: cleanFirst,
createIfMissing: createIfMissing, createIfMissing: createIfMissing,
restoreType: restoreType, restoreType: restoreType,
status: "Initializing...", cleanClusterFirst: cleanClusterFirst,
phase: "Starting", existingDBs: existingDBs,
startTime: time.Now(), status: "Initializing...",
details: []string{}, phase: "Starting",
spinnerFrames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, startTime: time.Now(),
spinnerFrame: 0, details: []string{},
spinnerFrames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
spinnerFrame: 0,
} }
} }
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
@@ -89,8 +92,10 @@ 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":
// Toggle create if missing if m.mode == "restore-cluster" {
m.createIfMissing = !m.createIfMissing // Toggle cluster cleanup
m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing) 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", " ": 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"))
} }