Final debug pass

This commit is contained in:
2025-10-24 19:03:06 +00:00
parent 4e281cff01
commit f014a20b10
12 changed files with 802 additions and 321 deletions

View File

@ -50,6 +50,8 @@ dbbackup interactive --database your_database
dbbackup interactive --database postgres --host localhost --user postgres dbbackup interactive --database postgres --host localhost --user postgres
``` ```
> Tip: In the interactive menu, tap the left/right arrows (or `t`) to toggle between PostgreSQL and MySQL/MariaDB before starting a task.
### 📊 Command Line with Progress ### 📊 Command Line with Progress
```bash ```bash
# Single backup with live progress # Single backup with live progress
@ -118,6 +120,9 @@ dbbackup status --db-type postgres
# Single database backup # Single database backup
dbbackup backup single myapp_db --db-type mysql dbbackup backup single myapp_db --db-type mysql
# Using the short flag for database selection
dbbackup backup single myapp_db -d mysql
# Sample backup # Sample backup
dbbackup backup sample myapp_db --sample-ratio 10 --db-type mysql dbbackup backup sample myapp_db --sample-ratio 10 --db-type mysql
@ -145,7 +150,7 @@ dbbackup backup cluster --jobs 16 --dump-jobs 8 --max-cores 32
| `--host` | Database host | `--host db.example.com` | | `--host` | Database host | `--host db.example.com` |
| `--port` | Database port | `--port 5432` | | `--port` | Database port | `--port 5432` |
| `--user` | Database user | `--user backup_user` | | `--user` | Database user | `--user backup_user` |
| `--db-type` | Database type | `--db-type mysql` | | `-d`, `--db-type` | Database type (`postgres`, `mysql`, `mariadb`) | `-d mysql` |
| `--insecure` | Disable SSL | `--insecure` | | `--insecure` | Disable SSL | `--insecure` |
| `--jobs` | Parallel jobs | `--jobs 8` | | `--jobs` | Parallel jobs | `--jobs 8` |
| `--debug` | Debug mode | `--debug` | | `--debug` | Debug mode | `--debug` |

View File

@ -111,6 +111,8 @@ dbbackup menu --database postgres --host localhost --user postgres
dbbackup ui --database myapp_db --progress dbbackup ui --database myapp_db --progress
``` ```
> 💡 In the interactive menu, use the left/right arrow keys (or press `t`) to switch the target engine between PostgreSQL and MySQL/MariaDB before launching an operation.
### Enhanced Progress Tracking Commands ### Enhanced Progress Tracking Commands
#### Real-Time Progress Monitoring #### Real-Time Progress Monitoring
@ -160,6 +162,9 @@ dbbackup restore backup.dump --progress --verify --show-steps
# Single database backup (auto-optimized for your CPU) # Single database backup (auto-optimized for your CPU)
dbbackup backup single myapp_db --db-type postgres dbbackup backup single myapp_db --db-type postgres
# MySQL/MariaDB backup using the short flag
dbbackup backup single myapp_db -d mysql --host mysql.example.com --port 3306
# Sample backup (10% of data) # Sample backup (10% of data)
dbbackup backup sample myapp_db --sample-ratio 10 dbbackup backup sample myapp_db --sample-ratio 10
@ -245,7 +250,7 @@ dbbackup cpu
| `--port` | Database port | `5432` (PG), `3306` (MySQL) | `--port 5432` | | `--port` | Database port | `5432` (PG), `3306` (MySQL) | `--port 5432` |
| `--user` | Database user | `postgres` (PG), `root` (MySQL) | `--user backup_user` | | `--user` | Database user | `postgres` (PG), `root` (MySQL) | `--user backup_user` |
| `--database` | Database name | `postgres` | `--database myapp_db` | | `--database` | Database name | `postgres` | `--database myapp_db` |
| `--db-type` | Database type | `postgres` | `--db-type mysql` | | `-d`, `--db-type` | Database type (`postgres`, `mysql`, `mariadb`) | `postgres` | `-d mysql` |
| `--ssl-mode` | SSL mode | `prefer` | `--ssl-mode require` | | `--ssl-mode` | SSL mode | `prefer` | `--ssl-mode require` |
| `--insecure` | Disable SSL | `false` | `--insecure` | | `--insecure` | Disable SSL | `false` | `--insecure` |
@ -258,6 +263,11 @@ export PG_PORT=5432
export PG_USER=postgres export PG_USER=postgres
export PGPASSWORD=secret export PGPASSWORD=secret
export DB_TYPE=postgres export DB_TYPE=postgres
export MYSQL_HOST=localhost
export MYSQL_PORT=3306
export MYSQL_USER=root
export MYSQL_PWD=secret
export MYSQL_DATABASE=myapp_db
# CPU optimization # CPU optimization
export AUTO_DETECT_CORES=true export AUTO_DETECT_CORES=true

View File

@ -1,16 +1,18 @@
package cmd package cmd
import ( import (
"compress/gzip"
"context" "context"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/spf13/cobra"
"dbbackup/internal/tui" "dbbackup/internal/tui"
"github.com/spf13/cobra"
) )
// Create placeholder commands for the other subcommands // Create placeholder commands for the other subcommands
@ -24,9 +26,6 @@ var restoreCmd = &cobra.Command{
if len(args) == 0 { if len(args) == 0 {
return fmt.Errorf("backup archive filename required") return fmt.Errorf("backup archive filename required")
} }
if len(args) == 0 {
return fmt.Errorf("backup archive filename required")
}
return runRestore(cmd.Context(), args[0]) return runRestore(cmd.Context(), args[0])
}, },
} }
@ -54,9 +53,9 @@ var listCmd = &cobra.Command{
} }
var interactiveCmd = &cobra.Command{ var interactiveCmd = &cobra.Command{
Use: "interactive", Use: "interactive",
Short: "Start interactive menu mode", Short: "Start interactive menu mode",
Long: `Start the interactive menu system for guided backup operations.`, Long: `Start the interactive menu system for guided backup operations.`,
Aliases: []string{"menu", "ui"}, Aliases: []string{"menu", "ui"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
// Start the interactive TUI // Start the interactive TUI
@ -82,8 +81,6 @@ var statusCmd = &cobra.Command{
}, },
} }
// runList lists available backups and databases // runList lists available backups and databases
func runList(ctx context.Context) error { func runList(ctx context.Context) error {
fmt.Println("==============================================================") fmt.Println("==============================================================")
@ -163,7 +160,7 @@ type backupFile struct {
func isBackupFile(filename string) bool { func isBackupFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename)) ext := strings.ToLower(filepath.Ext(filename))
return ext == ".dump" || ext == ".sql" || ext == ".tar" || ext == ".gz" || return ext == ".dump" || ext == ".sql" || ext == ".tar" || ext == ".gz" ||
strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".dump.gz") strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".dump.gz")
} }
// getBackupType determines backup type from filename // getBackupType determines backup type from filename
@ -391,10 +388,32 @@ func runRestore(ctx context.Context, archiveName string) error {
fmt.Println("🔄 Would execute: pg_restore to restore single database") fmt.Println("🔄 Would execute: pg_restore to restore single database")
fmt.Printf(" Command: pg_restore -h %s -p %d -U %s -d %s --verbose %s\n", fmt.Printf(" Command: pg_restore -h %s -p %d -U %s -d %s --verbose %s\n",
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath) cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
case "Single Database (.dump.gz)":
fmt.Println("🔄 Would execute: gunzip and pg_restore to restore single database")
fmt.Printf(" Command: gunzip -c %s | pg_restore -h %s -p %d -U %s -d %s --verbose\n",
archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database)
case "SQL Script (.sql)": case "SQL Script (.sql)":
fmt.Println("🔄 Would execute: psql to run SQL script") if cfg.IsPostgreSQL() {
fmt.Printf(" Command: psql -h %s -p %d -U %s -d %s -f %s\n", fmt.Println("🔄 Would execute: psql to run SQL script")
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath) fmt.Printf(" Command: psql -h %s -p %d -U %s -d %s -f %s\n",
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
} else if cfg.IsMySQL() {
fmt.Println("🔄 Would execute: mysql to run SQL script")
fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, false))
} else {
fmt.Println("🔄 Would execute: SQL client to run script (database type unknown)")
}
case "SQL Script (.sql.gz)":
if cfg.IsPostgreSQL() {
fmt.Println("🔄 Would execute: gunzip and psql to run SQL script")
fmt.Printf(" Command: gunzip -c %s | psql -h %s -p %d -U %s -d %s\n",
archivePath, cfg.Host, cfg.Port, cfg.User, cfg.Database)
} else if cfg.IsMySQL() {
fmt.Println("🔄 Would execute: gunzip and mysql to run SQL script")
fmt.Printf(" Command: %s\n", mysqlRestoreCommand(archivePath, true))
} else {
fmt.Println("🔄 Would execute: gunzip and SQL client to run script (database type unknown)")
}
case "Cluster Backup (.tar.gz)": case "Cluster Backup (.tar.gz)":
fmt.Println("🔄 Would execute: Extract and restore cluster backup") fmt.Println("🔄 Would execute: Extract and restore cluster backup")
fmt.Println(" Steps:") fmt.Println(" Steps:")
@ -415,8 +434,12 @@ func runRestore(ctx context.Context, archiveName string) error {
func detectArchiveType(filename string) string { func detectArchiveType(filename string) string {
switch { switch {
case strings.HasSuffix(filename, ".dump.gz"):
return "Single Database (.dump.gz)"
case strings.HasSuffix(filename, ".dump"): case strings.HasSuffix(filename, ".dump"):
return "Single Database (.dump)" return "Single Database (.dump)"
case strings.HasSuffix(filename, ".sql.gz"):
return "SQL Script (.sql.gz)"
case strings.HasSuffix(filename, ".sql"): case strings.HasSuffix(filename, ".sql"):
return "SQL Script (.sql)" return "SQL Script (.sql)"
case strings.HasSuffix(filename, ".tar.gz"): case strings.HasSuffix(filename, ".tar.gz"):
@ -496,6 +519,16 @@ func runVerify(ctx context.Context, archiveName string) error {
} }
checksRun++ checksRun++
case "Single Database (.dump.gz)":
fmt.Print("🔍 PostgreSQL dump format check (gzip)... ")
if err := verifyPgDumpGzip(archivePath); err != nil {
fmt.Printf("❌ FAILED: %v\n", err)
} else {
fmt.Println("✅ PASSED")
checksPassed++
}
checksRun++
case "SQL Script (.sql)": case "SQL Script (.sql)":
fmt.Print("📜 SQL script validation... ") fmt.Print("📜 SQL script validation... ")
if err := verifySqlScript(archivePath); err != nil { if err := verifySqlScript(archivePath); err != nil {
@ -506,6 +539,16 @@ func runVerify(ctx context.Context, archiveName string) error {
} }
checksRun++ checksRun++
case "SQL Script (.sql.gz)":
fmt.Print("📜 SQL script validation (gzip)... ")
if err := verifyGzipSqlScript(archivePath); err != nil {
fmt.Printf("❌ FAILED: %v\n", err)
} else {
fmt.Println("✅ PASSED")
checksPassed++
}
checksRun++
case "Cluster Backup (.tar.gz)": case "Cluster Backup (.tar.gz)":
fmt.Print("📦 Archive extraction test... ") fmt.Print("📦 Archive extraction test... ")
if err := verifyTarGz(archivePath); err != nil { if err := verifyTarGz(archivePath); err != nil {
@ -551,18 +594,41 @@ func verifyPgDump(path string) error {
} }
defer file.Close() defer file.Close()
buffer := make([]byte, 100) buffer := make([]byte, 512)
n, err := file.Read(buffer) n, err := file.Read(buffer)
if err != nil && n == 0 { if err != nil && err != io.EOF {
return fmt.Errorf("cannot read file: %w", err)
}
if n == 0 {
return fmt.Errorf("cannot read file") return fmt.Errorf("cannot read file")
} }
content := string(buffer[:n]) return checkPgDumpSignature(buffer[:n])
if strings.Contains(content, "PostgreSQL") || strings.Contains(content, "pg_dump") { }
return nil
func verifyPgDumpGzip(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to open gzip stream: %w", err)
}
defer gz.Close()
buffer := make([]byte, 512)
n, err := gz.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("cannot read gzip contents: %w", err)
}
if n == 0 {
return fmt.Errorf("gzip archive is empty")
} }
return fmt.Errorf("does not appear to be a PostgreSQL dump file") return checkPgDumpSignature(buffer[:n])
} }
func verifySqlScript(path string) error { func verifySqlScript(path string) error {
@ -573,19 +639,46 @@ func verifySqlScript(path string) error {
} }
defer file.Close() defer file.Close()
buffer := make([]byte, 500) buffer := make([]byte, 1024)
n, err := file.Read(buffer) n, err := file.Read(buffer)
if err != nil && n == 0 { if err != nil && err != io.EOF {
return fmt.Errorf("cannot read file: %w", err)
}
if n == 0 {
return fmt.Errorf("cannot read file") return fmt.Errorf("cannot read file")
} }
content := strings.ToLower(string(buffer[:n])) if containsSQLKeywords(strings.ToLower(string(buffer[:n]))) {
sqlKeywords := []string{"select", "insert", "create", "drop", "alter", "database", "table"} return nil
}
for _, keyword := range sqlKeywords { return fmt.Errorf("does not appear to contain SQL content")
if strings.Contains(content, keyword) { }
return nil
} func verifyGzipSqlScript(path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
gz, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to open gzip stream: %w", err)
}
defer gz.Close()
buffer := make([]byte, 1024)
n, err := gz.Read(buffer)
if err != nil && err != io.EOF {
return fmt.Errorf("cannot read gzip contents: %w", err)
}
if n == 0 {
return fmt.Errorf("gzip archive is empty")
}
if containsSQLKeywords(strings.ToLower(string(buffer[:n]))) {
return nil
} }
return fmt.Errorf("does not appear to contain SQL content") return fmt.Errorf("does not appear to contain SQL content")
@ -612,3 +705,53 @@ func verifyTarGz(path string) error {
return fmt.Errorf("does not appear to be a valid gzip file") return fmt.Errorf("does not appear to be a valid gzip file")
} }
func checkPgDumpSignature(data []byte) error {
if len(data) >= 5 && string(data[:5]) == "PGDMP" {
return nil
}
content := strings.ToLower(string(data))
if strings.Contains(content, "postgresql") || strings.Contains(content, "pg_dump") {
return nil
}
return fmt.Errorf("does not appear to be a PostgreSQL dump file")
}
func containsSQLKeywords(content string) bool {
sqlKeywords := []string{"select", "insert", "create", "drop", "alter", "database", "table", "update", "delete"}
for _, keyword := range sqlKeywords {
if strings.Contains(content, keyword) {
return true
}
}
return false
}
func mysqlRestoreCommand(archivePath string, compressed bool) string {
parts := []string{
"mysql",
"-h", cfg.Host,
"-P", fmt.Sprintf("%d", cfg.Port),
"-u", cfg.User,
}
if cfg.Password != "" {
parts = append(parts, fmt.Sprintf("-p'%s'", cfg.Password))
}
if cfg.Database != "" {
parts = append(parts, cfg.Database)
}
command := strings.Join(parts, " ")
if compressed {
return fmt.Sprintf("gunzip -c %s | %s", archivePath, command)
}
return fmt.Sprintf("%s < %s", command, archivePath)
}

View File

@ -4,9 +4,9 @@ import (
"context" "context"
"fmt" "fmt"
"github.com/spf13/cobra"
"dbbackup/internal/config" "dbbackup/internal/config"
"dbbackup/internal/logger" "dbbackup/internal/logger"
"github.com/spf13/cobra"
) )
var ( var (
@ -34,6 +34,12 @@ Database Support:
For help with specific commands, use: dbbackup [command] --help`, For help with specific commands, use: dbbackup [command] --help`,
Version: "", Version: "",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if cfg == nil {
return nil
}
return cfg.SetDatabaseType(cfg.DatabaseType)
},
} }
// Execute adds all child commands to the root command and sets flags appropriately. // Execute adds all child commands to the root command and sets flags appropriately.
@ -51,7 +57,7 @@ func Execute(ctx context.Context, config *config.Config, logger logger.Logger) e
rootCmd.PersistentFlags().StringVar(&cfg.User, "user", cfg.User, "Database user") rootCmd.PersistentFlags().StringVar(&cfg.User, "user", cfg.User, "Database user")
rootCmd.PersistentFlags().StringVar(&cfg.Database, "database", cfg.Database, "Database name") rootCmd.PersistentFlags().StringVar(&cfg.Database, "database", cfg.Database, "Database name")
rootCmd.PersistentFlags().StringVar(&cfg.Password, "password", cfg.Password, "Database password") rootCmd.PersistentFlags().StringVar(&cfg.Password, "password", cfg.Password, "Database password")
rootCmd.PersistentFlags().StringVar(&cfg.DatabaseType, "db-type", cfg.DatabaseType, "Database type (postgres|mysql)") rootCmd.PersistentFlags().StringVarP(&cfg.DatabaseType, "db-type", "d", cfg.DatabaseType, "Database type (postgres|mysql|mariadb)")
rootCmd.PersistentFlags().StringVar(&cfg.BackupDir, "backup-dir", cfg.BackupDir, "Backup directory") rootCmd.PersistentFlags().StringVar(&cfg.BackupDir, "backup-dir", cfg.BackupDir, "Backup directory")
rootCmd.PersistentFlags().BoolVar(&cfg.NoColor, "no-color", cfg.NoColor, "Disable colored output") rootCmd.PersistentFlags().BoolVar(&cfg.NoColor, "no-color", cfg.NoColor, "Disable colored output")
rootCmd.PersistentFlags().BoolVar(&cfg.Debug, "debug", cfg.Debug, "Enable debug logging") rootCmd.PersistentFlags().BoolVar(&cfg.Debug, "debug", cfg.Debug, "Enable debug logging")

Binary file not shown.

View File

@ -5,6 +5,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"dbbackup/internal/cpu" "dbbackup/internal/cpu"
) )
@ -64,15 +65,41 @@ func New() *Config {
cpuDetector := cpu.NewDetector() cpuDetector := cpu.NewDetector()
cpuInfo, _ := cpuDetector.DetectCPU() cpuInfo, _ := cpuDetector.DetectCPU()
return &Config{ dbTypeRaw := getEnvString("DB_TYPE", "postgres")
canonicalType, ok := canonicalDatabaseType(dbTypeRaw)
if !ok {
canonicalType = "postgres"
}
host := getEnvString("PG_HOST", "localhost")
port := getEnvInt("PG_PORT", postgresDefaultPort)
user := getEnvString("PG_USER", getCurrentUser())
databaseName := getEnvString("PG_DATABASE", "postgres")
password := getEnvString("PGPASSWORD", "")
sslMode := getEnvString("PG_SSLMODE", "prefer")
if canonicalType == "mysql" {
host = getEnvString("MYSQL_HOST", host)
port = getEnvInt("MYSQL_PORT", mysqlDefaultPort)
user = getEnvString("MYSQL_USER", user)
if db := getEnvString("MYSQL_DATABASE", ""); db != "" {
databaseName = db
}
if pwd := getEnvString("MYSQL_PWD", ""); pwd != "" {
password = pwd
}
sslMode = ""
}
cfg := &Config{
// Database defaults // Database defaults
Host: getEnvString("PG_HOST", "localhost"), Host: host,
Port: getEnvInt("PG_PORT", 5432), Port: port,
User: getEnvString("PG_USER", getCurrentUser()), User: user,
Database: getEnvString("PG_DATABASE", "postgres"), Database: databaseName,
Password: getEnvString("PGPASSWORD", ""), Password: password,
DatabaseType: getEnvString("DB_TYPE", "postgres"), DatabaseType: canonicalType,
SSLMode: getEnvString("PG_SSLMODE", "prefer"), SSLMode: sslMode,
Insecure: getEnvBool("INSECURE", false), Insecure: getEnvBool("INSECURE", false),
// Backup defaults // Backup defaults
@ -103,6 +130,15 @@ func New() *Config {
SingleDBName: getEnvString("SINGLE_DB_NAME", ""), SingleDBName: getEnvString("SINGLE_DB_NAME", ""),
RestoreDBName: getEnvString("RESTORE_DB_NAME", ""), RestoreDBName: getEnvString("RESTORE_DB_NAME", ""),
} }
// Ensure canonical defaults are enforced
if err := cfg.SetDatabaseType(cfg.DatabaseType); err != nil {
cfg.DatabaseType = "postgres"
cfg.Port = postgresDefaultPort
cfg.SSLMode = "prefer"
}
return cfg
} }
// UpdateFromEnvironment updates configuration from environment variables // UpdateFromEnvironment updates configuration from environment variables
@ -117,8 +153,8 @@ func (c *Config) UpdateFromEnvironment() {
// Validate validates the configuration // Validate validates the configuration
func (c *Config) Validate() error { func (c *Config) Validate() error {
if c.DatabaseType != "postgres" && c.DatabaseType != "mysql" { if err := c.SetDatabaseType(c.DatabaseType); err != nil {
return &ConfigError{Field: "database-type", Value: c.DatabaseType, Message: "must be 'postgres' or 'mysql'"} return err
} }
if c.CompressionLevel < 0 || c.CompressionLevel > 9 { if c.CompressionLevel < 0 || c.CompressionLevel > 9 {
@ -154,6 +190,55 @@ func (c *Config) GetDefaultPort() int {
return 5432 return 5432
} }
// DisplayDatabaseType returns a human-friendly name for the database type
func (c *Config) DisplayDatabaseType() string {
switch c.DatabaseType {
case "postgres":
return "PostgreSQL"
case "mysql":
return "MySQL/MariaDB"
default:
return c.DatabaseType
}
}
// SetDatabaseType normalizes the database type and updates dependent defaults
func (c *Config) SetDatabaseType(dbType string) error {
normalized, ok := canonicalDatabaseType(dbType)
if !ok {
return &ConfigError{Field: "database-type", Value: dbType, Message: "must be 'postgres' or 'mysql'"}
}
previous := c.DatabaseType
previousPort := c.Port
c.DatabaseType = normalized
if c.Port == 0 {
c.Port = defaultPortFor(normalized)
}
if normalized != previous {
if previousPort == defaultPortFor(previous) || previousPort == 0 {
c.Port = defaultPortFor(normalized)
}
}
// Adjust SSL mode defaults when switching engines. Preserve explicit user choices.
switch normalized {
case "mysql":
if strings.EqualFold(c.SSLMode, "prefer") || strings.EqualFold(c.SSLMode, "preferred") {
c.SSLMode = ""
}
case "postgres":
if c.SSLMode == "" {
c.SSLMode = "prefer"
}
}
return nil
}
// OptimizeForCPU optimizes job settings based on detected CPU // OptimizeForCPU optimizes job settings based on detected CPU
func (c *Config) OptimizeForCPU() error { func (c *Config) OptimizeForCPU() error {
if c.CPUDetector == nil { if c.CPUDetector == nil {
@ -216,6 +301,33 @@ func (e *ConfigError) Error() string {
return "config error in field '" + e.Field + "' with value '" + e.Value + "': " + e.Message return "config error in field '" + e.Field + "' with value '" + e.Value + "': " + e.Message
} }
const (
postgresDefaultPort = 5432
mysqlDefaultPort = 3306
)
func canonicalDatabaseType(input string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(input)) {
case "postgres", "postgresql", "pg":
return "postgres", true
case "mysql", "mariadb", "mariadb-server", "maria":
return "mysql", true
default:
return "", false
}
}
func defaultPortFor(dbType string) int {
switch dbType {
case "postgres":
return postgresDefaultPort
case "mysql":
return mysqlDefaultPort
default:
return postgresDefaultPort
}
}
// Helper functions // Helper functions
func getEnvString(key, defaultValue string) string { func getEnvString(key, defaultValue string) string {
if value := os.Getenv(key); value != "" { if value := os.Getenv(key); value != "" {

View File

@ -249,27 +249,24 @@ func (m *MySQL) BuildBackupCommand(database, outputFile string, options BackupOp
// SSL options // SSL options
if m.cfg.Insecure { if m.cfg.Insecure {
cmd = append(cmd, "--skip-ssl") cmd = append(cmd, "--skip-ssl")
} else if m.cfg.SSLMode != "" { } else if mode := strings.ToLower(m.cfg.SSLMode); mode != "" {
// MySQL SSL modes: DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY switch mode {
switch strings.ToLower(m.cfg.SSLMode) {
case "disable", "disabled":
cmd = append(cmd, "--skip-ssl")
case "require", "required": case "require", "required":
cmd = append(cmd, "--ssl-mode=REQUIRED") cmd = append(cmd, "--ssl-mode=REQUIRED")
case "verify-ca": case "verify-ca":
cmd = append(cmd, "--ssl-mode=VERIFY_CA") cmd = append(cmd, "--ssl-mode=VERIFY_CA")
case "verify-full", "verify-identity": case "verify-full", "verify-identity":
cmd = append(cmd, "--ssl-mode=VERIFY_IDENTITY") cmd = append(cmd, "--ssl-mode=VERIFY_IDENTITY")
default: case "disable", "disabled":
cmd = append(cmd, "--ssl-mode=PREFERRED") cmd = append(cmd, "--skip-ssl")
} }
} }
// Backup options // Backup options
cmd = append(cmd, "--single-transaction") // Consistent backup cmd = append(cmd, "--single-transaction") // Consistent backup
cmd = append(cmd, "--routines") // Include stored procedures/functions cmd = append(cmd, "--routines") // Include stored procedures/functions
cmd = append(cmd, "--triggers") // Include triggers cmd = append(cmd, "--triggers") // Include triggers
cmd = append(cmd, "--events") // Include events cmd = append(cmd, "--events") // Include events
if options.SchemaOnly { if options.SchemaOnly {
cmd = append(cmd, "--no-data") cmd = append(cmd, "--no-data")
@ -376,14 +373,12 @@ func (m *MySQL) buildDSN() string {
// Add connection parameters // Add connection parameters
params := []string{} params := []string{}
if m.cfg.Insecure { if !m.cfg.Insecure {
params = append(params, "tls=skip-verify")
} else if m.cfg.SSLMode != "" {
switch strings.ToLower(m.cfg.SSLMode) { switch strings.ToLower(m.cfg.SSLMode) {
case "disable", "disabled":
params = append(params, "tls=false")
case "require", "required": case "require", "required":
params = append(params, "tls=true") params = append(params, "tls=true")
case "verify-ca", "verify-full", "verify-identity":
params = append(params, "tls=preferred")
} }
} }

View File

@ -3,6 +3,7 @@ package tui
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@ -23,8 +24,8 @@ var (
Foreground(lipgloss.Color("#626262")) Foreground(lipgloss.Color("#626262"))
menuSelectedStyle = lipgloss.NewStyle(). menuSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF75B7")). Foreground(lipgloss.Color("#FF75B7")).
Bold(true) Bold(true)
infoStyle = lipgloss.NewStyle(). infoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262")) Foreground(lipgloss.Color("#626262"))
@ -36,16 +37,27 @@ var (
errorStyle = lipgloss.NewStyle(). errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF6B6B")). Foreground(lipgloss.Color("#FF6B6B")).
Bold(true) Bold(true)
dbSelectorLabelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#57C7FF")).
Bold(true)
) )
type dbTypeOption struct {
label string
value string
}
// MenuModel represents the simple menu state // MenuModel represents the simple menu state
type MenuModel struct { type MenuModel struct {
choices []string choices []string
cursor int cursor int
config *config.Config config *config.Config
logger logger.Logger logger logger.Logger
quitting bool quitting bool
message string message string
dbTypes []dbTypeOption
dbTypeCursor int
// Background operations // Background operations
ctx context.Context ctx context.Context
@ -55,6 +67,16 @@ type MenuModel struct {
func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel { func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
dbTypes := []dbTypeOption{
{label: "PostgreSQL", value: "postgres"},
{label: "MySQL / MariaDB", value: "mysql"},
}
dbCursor := 0
if cfg.IsMySQL() {
dbCursor = 1
}
model := MenuModel{ model := MenuModel{
choices: []string{ choices: []string{
"Single Database Backup", "Single Database Backup",
@ -67,10 +89,12 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
"Clear Operation History", "Clear Operation History",
"Quit", "Quit",
}, },
config: cfg, config: cfg,
logger: log, logger: log,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
dbTypes: dbTypes,
dbTypeCursor: dbCursor,
} }
return model return model
@ -93,6 +117,24 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.quitting = true m.quitting = true
return m, tea.Quit return m, tea.Quit
case "left", "h":
if m.dbTypeCursor > 0 {
m.dbTypeCursor--
m.applyDatabaseSelection()
}
case "right", "l":
if m.dbTypeCursor < len(m.dbTypes)-1 {
m.dbTypeCursor++
m.applyDatabaseSelection()
}
case "t":
if len(m.dbTypes) > 0 {
m.dbTypeCursor = (m.dbTypeCursor + 1) % len(m.dbTypes)
m.applyDatabaseSelection()
}
case "up", "k": case "up", "k":
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
@ -146,9 +188,24 @@ func (m MenuModel) View() string {
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu") header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu")
s += fmt.Sprintf("\n%s\n\n", header) s += fmt.Sprintf("\n%s\n\n", header)
if len(m.dbTypes) > 0 {
options := make([]string, len(m.dbTypes))
for i, opt := range m.dbTypes {
if m.dbTypeCursor == i {
options[i] = menuSelectedStyle.Render(opt.label)
} else {
options[i] = menuStyle.Render(opt.label)
}
}
selector := fmt.Sprintf("Target Engine: %s", strings.Join(options, menuStyle.Render(" | ")))
s += dbSelectorLabelStyle.Render(selector) + "\n"
hint := infoStyle.Render("Switch with ←/→ or t • Cluster backup requires PostgreSQL")
s += hint + "\n\n"
}
// Database info // Database info
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)", dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
m.config.User, m.config.Host, m.config.Port, m.config.DatabaseType)) m.config.User, m.config.Host, m.config.Port, m.config.DisplayDatabaseType()))
s += fmt.Sprintf("%s\n\n", dbInfo) s += fmt.Sprintf("%s\n\n", dbInfo)
// Menu items // Menu items
@ -189,6 +246,10 @@ func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
// handleClusterBackup shows confirmation and executes cluster backup // handleClusterBackup shows confirmation and executes cluster backup
func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) { func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
if !m.config.IsPostgreSQL() {
m.message = errorStyle.Render("❌ Cluster backup is available only for PostgreSQL targets")
return m, nil
}
confirm := NewConfirmationModel(m.config, m.logger, m, confirm := NewConfirmationModel(m.config, m.logger, m,
"🗄️ Cluster Backup", "🗄️ Cluster Backup",
"This will backup ALL databases in the cluster. Continue?") "This will backup ALL databases in the cluster. Continue?")
@ -220,6 +281,31 @@ func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) {
return settingsModel, nil return settingsModel, nil
} }
func (m *MenuModel) applyDatabaseSelection() {
if m == nil || len(m.dbTypes) == 0 {
return
}
if m.dbTypeCursor < 0 || m.dbTypeCursor >= len(m.dbTypes) {
return
}
selection := m.dbTypes[m.dbTypeCursor]
if err := m.config.SetDatabaseType(selection.value); err != nil {
m.message = errorStyle.Render(fmt.Sprintf("❌ %v", err))
return
}
// Refresh default port if unchanged
if m.config.Port == 0 {
m.config.Port = m.config.GetDefaultPort()
}
m.message = successStyle.Render(fmt.Sprintf("🔀 Target database set to %s", m.config.DisplayDatabaseType()))
if m.logger != nil {
m.logger.Info("updated target database type", "type", m.config.DatabaseType, "port", m.config.Port)
}
}
// RunInteractiveMenu starts the simple TUI // RunInteractiveMenu starts the simple TUI
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error { func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
m := NewMenuModel(cfg, log) m := NewMenuModel(cfg, log)

View File

@ -14,11 +14,11 @@ import (
) )
var ( var (
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")).Padding(1, 2) headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")).Padding(1, 2)
inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
buttonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("57")).Padding(0, 2) buttonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("57")).Padding(0, 2)
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("57")).Bold(true) selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("57")).Bold(true)
detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
) )
// SettingsModel represents the settings configuration state // SettingsModel represents the settings configuration state
@ -50,6 +50,16 @@ type SettingItem struct {
// Initialize settings model // Initialize settings model
func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) SettingsModel { func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) SettingsModel {
settings := []SettingItem{ settings := []SettingItem{
{
Key: "database_type",
DisplayName: "Database Type",
Value: func(c *config.Config) string { return c.DatabaseType },
Update: func(c *config.Config, v string) error {
return c.SetDatabaseType(v)
},
Type: "string",
Description: "Target database engine (postgres, mysql, mariadb)",
},
{ {
Key: "backup_dir", Key: "backup_dir",
DisplayName: "Backup Directory", DisplayName: "Backup Directory",
@ -195,8 +205,12 @@ func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) S
{ {
Key: "auto_detect_cores", Key: "auto_detect_cores",
DisplayName: "Auto Detect CPU Cores", DisplayName: "Auto Detect CPU Cores",
Value: func(c *config.Config) string { Value: func(c *config.Config) string {
if c.AutoDetectCores { return "true" } else { return "false" } if c.AutoDetectCores {
return "true"
} else {
return "false"
}
}, },
Update: func(c *config.Config, v string) error { Update: func(c *config.Config, v string) error {
val, err := strconv.ParseBool(v) val, err := strconv.ParseBool(v)
@ -456,6 +470,10 @@ func (m SettingsModel) View() string {
for i, setting := range m.settings { for i, setting := range m.settings {
cursor := " " cursor := " "
value := setting.Value(m.config) value := setting.Value(m.config)
displayValue := value
if setting.Key == "database_type" {
displayValue = fmt.Sprintf("%s (%s)", value, m.config.DisplayDatabaseType())
}
if m.cursor == i { if m.cursor == i {
cursor = ">" cursor = ">"
@ -469,11 +487,11 @@ func (m SettingsModel) View() string {
b.WriteString(selectedStyle.Render(line)) b.WriteString(selectedStyle.Render(line))
b.WriteString(" ✏️") b.WriteString(" ✏️")
} else { } else {
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, value) line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue)
b.WriteString(selectedStyle.Render(line)) b.WriteString(selectedStyle.Render(line))
} }
} else { } else {
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, value) line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue)
b.WriteString(menuStyle.Render(line)) b.WriteString(menuStyle.Render(line))
} }
b.WriteString("\n") b.WriteString("\n")
@ -508,6 +526,7 @@ func (m SettingsModel) View() string {
b.WriteString("\n") b.WriteString("\n")
summary := []string{ summary := []string{
fmt.Sprintf("Target DB: %s (%s)", m.config.DisplayDatabaseType(), m.config.DatabaseType),
fmt.Sprintf("Database: %s@%s:%d", m.config.User, m.config.Host, m.config.Port), fmt.Sprintf("Database: %s@%s:%d", m.config.User, m.config.Host, m.config.Port),
fmt.Sprintf("Backup Dir: %s", m.config.BackupDir), fmt.Sprintf("Backup Dir: %s", m.config.BackupDir),
fmt.Sprintf("Compression: Level %d", m.config.CompressionLevel), fmt.Sprintf("Compression: Level %d", m.config.CompressionLevel),

View File

@ -148,7 +148,7 @@ func (m StatusViewModel) View() string {
} }
s.WriteString("\n") s.WriteString("\n")
s.WriteString(fmt.Sprintf("Database Type: %s\n", m.config.DatabaseType)) s.WriteString(fmt.Sprintf("Database Type: %s (%s)\n", m.config.DisplayDatabaseType(), m.config.DatabaseType))
s.WriteString(fmt.Sprintf("Host: %s:%d\n", m.config.Host, m.config.Port)) s.WriteString(fmt.Sprintf("Host: %s:%d\n", m.config.Host, m.config.Port))
s.WriteString(fmt.Sprintf("User: %s\n", m.config.User)) s.WriteString(fmt.Sprintf("User: %s\n", m.config.User))
s.WriteString(fmt.Sprintf("Backup Directory: %s\n", m.config.BackupDir)) s.WriteString(fmt.Sprintf("Backup Directory: %s\n", m.config.BackupDir))

173
scripts/cli_switch_test.sh Executable file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env bash
set -u
set -o pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
BINARY_NAME="dbbackup_linux_amd64"
BINARY="./${BINARY_NAME}"
LOG_DIR="${REPO_ROOT}/test_logs"
TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
LOG_FILE="${LOG_DIR}/cli_switch_test_${TIMESTAMP}.log"
PG_BACKUP_DIR="/tmp/db_backups"
PG_DATABASE="postgres"
PG_FLAGS=(
--db-type postgres
--host localhost
--port 5432
--user postgres
--database "${PG_DATABASE}"
--backup-dir "${PG_BACKUP_DIR}"
--jobs 4
--dump-jobs 4
--max-cores 8
--cpu-workload balanced
--debug
)
MYSQL_BACKUP_DIR="/tmp/mysql_backups"
MYSQL_DATABASE="backup_demo"
MYSQL_FLAGS=(
--db-type mysql
--host 127.0.0.1
--port 3306
--user backup_user
--password backup_pass
--database "${MYSQL_DATABASE}"
--backup-dir "${MYSQL_BACKUP_DIR}"
--insecure
--jobs 2
--dump-jobs 2
--max-cores 4
--cpu-workload io-intensive
--debug
)
mkdir -p "${LOG_DIR}"
log() {
printf '%s\n' "$1" | tee -a "${LOG_FILE}" >/dev/null
}
RESULTS=()
run_cmd() {
local label="$1"
shift
log ""
log "### ${label}"
log "Command: $*"
"$@" 2>&1 | tee -a "${LOG_FILE}"
local status=${PIPESTATUS[0]}
log "Exit: ${status}"
RESULTS+=("${label}|${status}")
}
latest_file() {
local dir="$1"
local pattern="$2"
shopt -s nullglob
local files=("${dir}"/${pattern})
shopt -u nullglob
if (( ${#files[@]} == 0 )); then
return 1
fi
local latest="${files[0]}"
for file in "${files[@]}"; do
if [[ "${file}" -nt "${latest}" ]]; then
latest="${file}"
fi
done
printf '%s\n' "${latest}"
}
log "dbbackup CLI regression started"
log "Log file: ${LOG_FILE}"
cd "${REPO_ROOT}"
run_cmd "Go build" go build -o "${BINARY}" .
run_cmd "Ensure Postgres backup dir" sudo -u postgres mkdir -p "${PG_BACKUP_DIR}"
run_cmd "Ensure MySQL backup dir" mkdir -p "${MYSQL_BACKUP_DIR}"
run_cmd "Postgres status" sudo -u postgres "${BINARY}" status "${PG_FLAGS[@]}"
run_cmd "Postgres preflight" sudo -u postgres "${BINARY}" preflight "${PG_FLAGS[@]}"
run_cmd "Postgres CPU info" sudo -u postgres "${BINARY}" cpu "${PG_FLAGS[@]}"
run_cmd "Postgres backup single" sudo -u postgres "${BINARY}" backup single "${PG_DATABASE}" "${PG_FLAGS[@]}"
run_cmd "Postgres backup sample" sudo -u postgres "${BINARY}" backup sample "${PG_DATABASE}" --sample-ratio 5 "${PG_FLAGS[@]}"
run_cmd "Postgres backup cluster" sudo -u postgres "${BINARY}" backup cluster "${PG_FLAGS[@]}"
run_cmd "Postgres list" sudo -u postgres "${BINARY}" list "${PG_FLAGS[@]}"
PG_SINGLE_FILE="$(latest_file "${PG_BACKUP_DIR}" "db_${PG_DATABASE}_*.dump" || true)"
PG_SAMPLE_FILE="$(latest_file "${PG_BACKUP_DIR}" "sample_${PG_DATABASE}_*.sql" || true)"
PG_CLUSTER_FILE="$(latest_file "${PG_BACKUP_DIR}" "cluster_*.tar.gz" || true)"
if [[ -n "${PG_SINGLE_FILE}" ]]; then
run_cmd "Postgres verify single" sudo -u postgres "${BINARY}" verify "$(basename "${PG_SINGLE_FILE}")" "${PG_FLAGS[@]}"
run_cmd "Postgres restore single" sudo -u postgres "${BINARY}" restore "$(basename "${PG_SINGLE_FILE}")" "${PG_FLAGS[@]}"
else
log "No PostgreSQL single backup found for verification"
RESULTS+=("Postgres single artifact missing|1")
fi
if [[ -n "${PG_SAMPLE_FILE}" ]]; then
run_cmd "Postgres verify sample" sudo -u postgres "${BINARY}" verify "$(basename "${PG_SAMPLE_FILE}")" "${PG_FLAGS[@]}"
run_cmd "Postgres restore sample" sudo -u postgres "${BINARY}" restore "$(basename "${PG_SAMPLE_FILE}")" "${PG_FLAGS[@]}"
else
log "No PostgreSQL sample backup found for verification"
RESULTS+=("Postgres sample artifact missing|1")
fi
if [[ -n "${PG_CLUSTER_FILE}" ]]; then
run_cmd "Postgres verify cluster" sudo -u postgres "${BINARY}" verify "$(basename "${PG_CLUSTER_FILE}")" "${PG_FLAGS[@]}"
run_cmd "Postgres restore cluster" sudo -u postgres "${BINARY}" restore "$(basename "${PG_CLUSTER_FILE}")" "${PG_FLAGS[@]}"
else
log "No PostgreSQL cluster backup found for verification"
RESULTS+=("Postgres cluster artifact missing|1")
fi
run_cmd "MySQL status" "${BINARY}" status "${MYSQL_FLAGS[@]}"
run_cmd "MySQL preflight" "${BINARY}" preflight "${MYSQL_FLAGS[@]}"
run_cmd "MySQL CPU info" "${BINARY}" cpu "${MYSQL_FLAGS[@]}"
run_cmd "MySQL backup single" "${BINARY}" backup single "${MYSQL_DATABASE}" "${MYSQL_FLAGS[@]}"
run_cmd "MySQL backup sample" "${BINARY}" backup sample "${MYSQL_DATABASE}" --sample-percent 25 "${MYSQL_FLAGS[@]}"
run_cmd "MySQL list" "${BINARY}" list "${MYSQL_FLAGS[@]}"
MYSQL_SINGLE_FILE="$(latest_file "${MYSQL_BACKUP_DIR}" "db_${MYSQL_DATABASE}_*.sql.gz" || true)"
MYSQL_SAMPLE_FILE="$(latest_file "${MYSQL_BACKUP_DIR}" "sample_${MYSQL_DATABASE}_*.sql" || true)"
if [[ -n "${MYSQL_SINGLE_FILE}" ]]; then
run_cmd "MySQL verify single" "${BINARY}" verify "$(basename "${MYSQL_SINGLE_FILE}")" "${MYSQL_FLAGS[@]}"
run_cmd "MySQL restore single" "${BINARY}" restore "$(basename "${MYSQL_SINGLE_FILE}")" "${MYSQL_FLAGS[@]}"
else
log "No MySQL single backup found for verification"
RESULTS+=("MySQL single artifact missing|1")
fi
if [[ -n "${MYSQL_SAMPLE_FILE}" ]]; then
run_cmd "MySQL verify sample" "${BINARY}" verify "$(basename "${MYSQL_SAMPLE_FILE}")" "${MYSQL_FLAGS[@]}"
run_cmd "MySQL restore sample" "${BINARY}" restore "$(basename "${MYSQL_SAMPLE_FILE}")" "${MYSQL_FLAGS[@]}"
else
log "No MySQL sample backup found for verification"
RESULTS+=("MySQL sample artifact missing|1")
fi
run_cmd "Interactive help" "${BINARY}" interactive --help
run_cmd "Root help" "${BINARY}" --help
run_cmd "Root version" "${BINARY}" --version
log ""
log "=== Summary ==="
failed=0
for entry in "${RESULTS[@]}"; do
IFS='|' read -r label status <<<"${entry}"
if [[ "${status}" -eq 0 ]]; then
log "[PASS] ${label}"
else
log "[FAIL] ${label} (exit ${status})"
failed=1
fi
done
exit "${failed}"

View File

@ -1,68 +0,0 @@
#!/bin/bash
echo "==================================="
echo " DB BACKUP TOOL - FUNCTION TEST"
echo "==================================="
echo
# Test all CLI commands
commands=("backup" "restore" "list" "status" "verify" "preflight" "cpu")
echo "1. Testing CLI Commands:"
echo "------------------------"
for cmd in "${commands[@]}"; do
echo -n " $cmd: "
output=$(./dbbackup_linux_amd64 $cmd --help 2>&1 | head -1)
if [[ "$output" == *"not yet implemented"* ]]; then
echo "❌ PLACEHOLDER"
elif [[ "$output" == *"Error: unknown command"* ]]; then
echo "❌ MISSING"
else
echo "✅ IMPLEMENTED"
fi
done
echo
echo "2. Testing Backup Subcommands:"
echo "------------------------------"
backup_cmds=("single" "cluster" "sample")
for cmd in "${backup_cmds[@]}"; do
echo -n " backup $cmd: "
output=$(./dbbackup_linux_amd64 backup $cmd --help 2>&1 | head -1)
if [[ "$output" == *"Error"* ]]; then
echo "❌ MISSING"
else
echo "✅ IMPLEMENTED"
fi
done
echo
echo "3. Testing Status & Connection:"
echo "------------------------------"
echo " Database connection test:"
./dbbackup_linux_amd64 status 2>&1 | grep -E "(✅|❌)" | head -3
echo
echo "4. Testing Interactive Mode:"
echo "----------------------------"
echo " Starting interactive (5 sec timeout)..."
timeout 5s ./dbbackup_linux_amd64 interactive >/dev/null 2>&1
if [ $? -eq 124 ]; then
echo " ✅ Interactive mode starts (timed out = working)"
else
echo " ❌ Interactive mode failed"
fi
echo
echo "5. Testing with Different DB Types:"
echo "----------------------------------"
echo " PostgreSQL config:"
./dbbackup_linux_amd64 --db-type postgres status 2>&1 | grep "Database Type" || echo " ❌ Failed"
echo " MySQL config:"
./dbbackup_linux_amd64 --db-type mysql status 2>&1 | grep "Database Type" || echo " ❌ Failed"
echo
echo "==================================="
echo " TEST COMPLETE"
echo "==================================="