Final debug pass
This commit is contained in:
@ -5,7 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
"strings"
|
||||
|
||||
"dbbackup/internal/cpu"
|
||||
)
|
||||
|
||||
@ -34,7 +35,7 @@ type Config struct {
|
||||
MaxCores int
|
||||
AutoDetectCores bool
|
||||
CPUWorkloadType string // "cpu-intensive", "io-intensive", "balanced"
|
||||
|
||||
|
||||
// CPU detection
|
||||
CPUDetector *cpu.Detector
|
||||
CPUInfo *cpu.CPUInfo
|
||||
@ -64,15 +65,41 @@ func New() *Config {
|
||||
cpuDetector := cpu.NewDetector()
|
||||
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
|
||||
Host: getEnvString("PG_HOST", "localhost"),
|
||||
Port: getEnvInt("PG_PORT", 5432),
|
||||
User: getEnvString("PG_USER", getCurrentUser()),
|
||||
Database: getEnvString("PG_DATABASE", "postgres"),
|
||||
Password: getEnvString("PGPASSWORD", ""),
|
||||
DatabaseType: getEnvString("DB_TYPE", "postgres"),
|
||||
SSLMode: getEnvString("PG_SSLMODE", "prefer"),
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Database: databaseName,
|
||||
Password: password,
|
||||
DatabaseType: canonicalType,
|
||||
SSLMode: sslMode,
|
||||
Insecure: getEnvBool("INSECURE", false),
|
||||
|
||||
// Backup defaults
|
||||
@ -103,6 +130,15 @@ func New() *Config {
|
||||
SingleDBName: getEnvString("SINGLE_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
|
||||
@ -117,18 +153,18 @@ func (c *Config) UpdateFromEnvironment() {
|
||||
|
||||
// Validate validates the configuration
|
||||
func (c *Config) Validate() error {
|
||||
if c.DatabaseType != "postgres" && c.DatabaseType != "mysql" {
|
||||
return &ConfigError{Field: "database-type", Value: c.DatabaseType, Message: "must be 'postgres' or 'mysql'"}
|
||||
if err := c.SetDatabaseType(c.DatabaseType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
if c.CompressionLevel < 0 || c.CompressionLevel > 9 {
|
||||
return &ConfigError{Field: "compression", Value: string(rune(c.CompressionLevel)), Message: "must be between 0-9"}
|
||||
}
|
||||
|
||||
|
||||
if c.Jobs < 1 {
|
||||
return &ConfigError{Field: "jobs", Value: string(rune(c.Jobs)), Message: "must be at least 1"}
|
||||
}
|
||||
|
||||
|
||||
if c.DumpJobs < 1 {
|
||||
return &ConfigError{Field: "dump-jobs", Value: string(rune(c.DumpJobs)), Message: "must be at least 1"}
|
||||
}
|
||||
@ -154,12 +190,61 @@ func (c *Config) GetDefaultPort() int {
|
||||
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
|
||||
func (c *Config) OptimizeForCPU() error {
|
||||
if c.CPUDetector == nil {
|
||||
c.CPUDetector = cpu.NewDetector()
|
||||
}
|
||||
|
||||
|
||||
if c.CPUInfo == nil {
|
||||
info, err := c.CPUDetector.DetectCPU()
|
||||
if err != nil {
|
||||
@ -167,13 +252,13 @@ func (c *Config) OptimizeForCPU() error {
|
||||
}
|
||||
c.CPUInfo = info
|
||||
}
|
||||
|
||||
|
||||
if c.AutoDetectCores {
|
||||
// Optimize jobs based on workload type
|
||||
if jobs, err := c.CPUDetector.CalculateOptimalJobs(c.CPUWorkloadType, c.MaxCores); err == nil {
|
||||
c.Jobs = jobs
|
||||
}
|
||||
|
||||
|
||||
// Optimize dump jobs (more conservative for database dumps)
|
||||
if dumpJobs, err := c.CPUDetector.CalculateOptimalJobs("cpu-intensive", c.MaxCores/2); err == nil {
|
||||
c.DumpJobs = dumpJobs
|
||||
@ -182,7 +267,7 @@ func (c *Config) OptimizeForCPU() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -191,16 +276,16 @@ func (c *Config) GetCPUInfo() (*cpu.CPUInfo, error) {
|
||||
if c.CPUInfo != nil {
|
||||
return c.CPUInfo, nil
|
||||
}
|
||||
|
||||
|
||||
if c.CPUDetector == nil {
|
||||
c.CPUDetector = cpu.NewDetector()
|
||||
}
|
||||
|
||||
|
||||
info, err := c.CPUDetector.DetectCPU()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
c.CPUInfo = info
|
||||
return info, nil
|
||||
}
|
||||
@ -216,6 +301,33 @@ func (e *ConfigError) Error() string {
|
||||
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
|
||||
func getEnvString(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
@ -258,17 +370,17 @@ func getDefaultBackupDir() string {
|
||||
if homeDir != "" {
|
||||
return filepath.Join(homeDir, "db_backups")
|
||||
}
|
||||
|
||||
|
||||
// Fallback based on OS
|
||||
if runtime.GOOS == "windows" {
|
||||
return "C:\\db_backups"
|
||||
}
|
||||
|
||||
|
||||
// For PostgreSQL user on Linux/Unix
|
||||
if getCurrentUser() == "postgres" {
|
||||
return "/var/lib/pgsql/pg_backups"
|
||||
}
|
||||
|
||||
|
||||
return "/tmp/db_backups"
|
||||
}
|
||||
|
||||
@ -316,4 +428,4 @@ func getDefaultMaxCores(cpuInfo *cpu.CPUInfo) int {
|
||||
maxCores = 64
|
||||
}
|
||||
return maxCores
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,28 +32,28 @@ func (m *MySQL) Connect(ctx context.Context) error {
|
||||
// Build MySQL DSN
|
||||
dsn := m.buildDSN()
|
||||
m.dsn = dsn
|
||||
|
||||
|
||||
m.log.Debug("Connecting to MySQL", "dsn", sanitizeMySQLDSN(dsn))
|
||||
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open MySQL connection: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Configure connection pool
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
|
||||
// Test connection
|
||||
timeoutCtx, cancel := buildTimeout(ctx, 0)
|
||||
defer cancel()
|
||||
|
||||
|
||||
if err := db.PingContext(timeoutCtx); err != nil {
|
||||
db.Close()
|
||||
return fmt.Errorf("failed to ping MySQL: %w", err)
|
||||
}
|
||||
|
||||
|
||||
m.db = db
|
||||
m.log.Info("Connected to MySQL successfully")
|
||||
return nil
|
||||
@ -64,15 +64,15 @@ func (m *MySQL) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
if m.db == nil {
|
||||
return nil, fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := `SHOW DATABASES`
|
||||
|
||||
|
||||
rows, err := m.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query databases: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
|
||||
var databases []string
|
||||
systemDbs := map[string]bool{
|
||||
"information_schema": true,
|
||||
@ -80,19 +80,19 @@ func (m *MySQL) ListDatabases(ctx context.Context) ([]string, error) {
|
||||
"mysql": true,
|
||||
"sys": true,
|
||||
}
|
||||
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan database name: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Skip system databases
|
||||
if !systemDbs[name] {
|
||||
databases = append(databases, name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return databases, rows.Err()
|
||||
}
|
||||
|
||||
@ -101,17 +101,17 @@ func (m *MySQL) ListTables(ctx context.Context, database string) ([]string, erro
|
||||
if m.db == nil {
|
||||
return nil, fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := `SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = ? AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name`
|
||||
|
||||
|
||||
rows, err := m.db.QueryContext(ctx, query, database)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query tables: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
@ -120,7 +120,7 @@ func (m *MySQL) ListTables(ctx context.Context, database string) ([]string, erro
|
||||
}
|
||||
tables = append(tables, name)
|
||||
}
|
||||
|
||||
|
||||
return tables, rows.Err()
|
||||
}
|
||||
|
||||
@ -129,13 +129,13 @@ func (m *MySQL) CreateDatabase(ctx context.Context, name string) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", name)
|
||||
_, err := m.db.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database %s: %w", name, err)
|
||||
}
|
||||
|
||||
|
||||
m.log.Info("Created database", "name", name)
|
||||
return nil
|
||||
}
|
||||
@ -145,13 +145,13 @@ func (m *MySQL) DropDatabase(ctx context.Context, name string) error {
|
||||
if m.db == nil {
|
||||
return fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", name)
|
||||
_, err := m.db.ExecContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop database %s: %w", name, err)
|
||||
}
|
||||
|
||||
|
||||
m.log.Info("Dropped database", "name", name)
|
||||
return nil
|
||||
}
|
||||
@ -161,7 +161,7 @@ func (m *MySQL) DatabaseExists(ctx context.Context, name string) (bool, error) {
|
||||
if m.db == nil {
|
||||
return false, fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`
|
||||
var dbName string
|
||||
err := m.db.QueryRowContext(ctx, query, name).Scan(&dbName)
|
||||
@ -171,7 +171,7 @@ func (m *MySQL) DatabaseExists(ctx context.Context, name string) (bool, error) {
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check database existence: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@ -180,13 +180,13 @@ func (m *MySQL) GetVersion(ctx context.Context) (string, error) {
|
||||
if m.db == nil {
|
||||
return "", fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
var version string
|
||||
err := m.db.QueryRowContext(ctx, "SELECT VERSION()").Scan(&version)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get version: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return version, nil
|
||||
}
|
||||
|
||||
@ -195,17 +195,17 @@ func (m *MySQL) GetDatabaseSize(ctx context.Context, database string) (int64, er
|
||||
if m.db == nil {
|
||||
return 0, fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
query := `SELECT COALESCE(SUM(data_length + index_length), 0) as size_bytes
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?`
|
||||
|
||||
|
||||
var size int64
|
||||
err := m.db.QueryRowContext(ctx, query, database).Scan(&size)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get database size: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
@ -214,11 +214,11 @@ func (m *MySQL) GetTableRowCount(ctx context.Context, database, table string) (i
|
||||
if m.db == nil {
|
||||
return 0, fmt.Errorf("not connected to database")
|
||||
}
|
||||
|
||||
|
||||
// First try information_schema for approximate count (faster)
|
||||
query := `SELECT table_rows FROM information_schema.tables
|
||||
WHERE table_schema = ? AND table_name = ?`
|
||||
|
||||
|
||||
var count int64
|
||||
err := m.db.QueryRowContext(ctx, query, database, table).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
@ -229,95 +229,92 @@ func (m *MySQL) GetTableRowCount(ctx context.Context, database, table string) (i
|
||||
return 0, fmt.Errorf("failed to get table row count: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// BuildBackupCommand builds mysqldump command
|
||||
func (m *MySQL) BuildBackupCommand(database, outputFile string, options BackupOptions) []string {
|
||||
cmd := []string{"mysqldump"}
|
||||
|
||||
|
||||
// Connection parameters
|
||||
cmd = append(cmd, "-h", m.cfg.Host)
|
||||
cmd = append(cmd, "-P", strconv.Itoa(m.cfg.Port))
|
||||
cmd = append(cmd, "-u", m.cfg.User)
|
||||
|
||||
|
||||
if m.cfg.Password != "" {
|
||||
cmd = append(cmd, "-p"+m.cfg.Password)
|
||||
}
|
||||
|
||||
|
||||
// SSL options
|
||||
if m.cfg.Insecure {
|
||||
cmd = append(cmd, "--skip-ssl")
|
||||
} else if m.cfg.SSLMode != "" {
|
||||
// MySQL SSL modes: DISABLED, PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY
|
||||
switch strings.ToLower(m.cfg.SSLMode) {
|
||||
case "disable", "disabled":
|
||||
cmd = append(cmd, "--skip-ssl")
|
||||
} else if mode := strings.ToLower(m.cfg.SSLMode); mode != "" {
|
||||
switch mode {
|
||||
case "require", "required":
|
||||
cmd = append(cmd, "--ssl-mode=REQUIRED")
|
||||
case "verify-ca":
|
||||
cmd = append(cmd, "--ssl-mode=VERIFY_CA")
|
||||
case "verify-full", "verify-identity":
|
||||
cmd = append(cmd, "--ssl-mode=VERIFY_IDENTITY")
|
||||
default:
|
||||
cmd = append(cmd, "--ssl-mode=PREFERRED")
|
||||
case "disable", "disabled":
|
||||
cmd = append(cmd, "--skip-ssl")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Backup options
|
||||
cmd = append(cmd, "--single-transaction") // Consistent backup
|
||||
cmd = append(cmd, "--routines") // Include stored procedures/functions
|
||||
cmd = append(cmd, "--triggers") // Include triggers
|
||||
cmd = append(cmd, "--events") // Include events
|
||||
|
||||
cmd = append(cmd, "--single-transaction") // Consistent backup
|
||||
cmd = append(cmd, "--routines") // Include stored procedures/functions
|
||||
cmd = append(cmd, "--triggers") // Include triggers
|
||||
cmd = append(cmd, "--events") // Include events
|
||||
|
||||
if options.SchemaOnly {
|
||||
cmd = append(cmd, "--no-data")
|
||||
} else if options.DataOnly {
|
||||
cmd = append(cmd, "--no-create-info")
|
||||
}
|
||||
|
||||
|
||||
if options.NoOwner || options.NoPrivileges {
|
||||
cmd = append(cmd, "--skip-add-drop-table")
|
||||
}
|
||||
|
||||
|
||||
// Compression (handled externally for MySQL)
|
||||
// Output redirection will be handled by caller
|
||||
|
||||
|
||||
// Database
|
||||
cmd = append(cmd, database)
|
||||
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// BuildRestoreCommand builds mysql restore command
|
||||
func (m *MySQL) BuildRestoreCommand(database, inputFile string, options RestoreOptions) []string {
|
||||
cmd := []string{"mysql"}
|
||||
|
||||
|
||||
// Connection parameters
|
||||
cmd = append(cmd, "-h", m.cfg.Host)
|
||||
cmd = append(cmd, "-P", strconv.Itoa(m.cfg.Port))
|
||||
cmd = append(cmd, "-u", m.cfg.User)
|
||||
|
||||
|
||||
if m.cfg.Password != "" {
|
||||
cmd = append(cmd, "-p"+m.cfg.Password)
|
||||
}
|
||||
|
||||
|
||||
// SSL options
|
||||
if m.cfg.Insecure {
|
||||
cmd = append(cmd, "--skip-ssl")
|
||||
}
|
||||
|
||||
|
||||
// Options
|
||||
if options.SingleTransaction {
|
||||
cmd = append(cmd, "--single-transaction")
|
||||
}
|
||||
|
||||
|
||||
// Database
|
||||
cmd = append(cmd, database)
|
||||
|
||||
|
||||
// Input file (will be handled via stdin redirection)
|
||||
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -326,11 +323,11 @@ func (m *MySQL) BuildSampleQuery(database, table string, strategy SampleStrategy
|
||||
switch strategy.Type {
|
||||
case "ratio":
|
||||
// Every Nth record using row_number (MySQL 8.0+) or modulo
|
||||
return fmt.Sprintf("SELECT * FROM (SELECT *, (@row_number:=@row_number + 1) AS rn FROM %s.%s CROSS JOIN (SELECT @row_number:=0) AS t) AS numbered WHERE rn %% %d = 1",
|
||||
return fmt.Sprintf("SELECT * FROM (SELECT *, (@row_number:=@row_number + 1) AS rn FROM %s.%s CROSS JOIN (SELECT @row_number:=0) AS t) AS numbered WHERE rn %% %d = 1",
|
||||
database, table, strategy.Value)
|
||||
case "percent":
|
||||
// Percentage sampling using RAND()
|
||||
return fmt.Sprintf("SELECT * FROM %s.%s WHERE RAND() <= %f",
|
||||
return fmt.Sprintf("SELECT * FROM %s.%s WHERE RAND() <= %f",
|
||||
database, table, float64(strategy.Value)/100.0)
|
||||
case "count":
|
||||
// First N records
|
||||
@ -343,58 +340,56 @@ func (m *MySQL) BuildSampleQuery(database, table string, strategy SampleStrategy
|
||||
// ValidateBackupTools checks if required MySQL tools are available
|
||||
func (m *MySQL) ValidateBackupTools() error {
|
||||
tools := []string{"mysqldump", "mysql"}
|
||||
|
||||
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
return fmt.Errorf("required tool not found: %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDSN constructs MySQL connection string
|
||||
func (m *MySQL) buildDSN() string {
|
||||
dsn := ""
|
||||
|
||||
|
||||
if m.cfg.User != "" {
|
||||
dsn += m.cfg.User
|
||||
}
|
||||
|
||||
|
||||
if m.cfg.Password != "" {
|
||||
dsn += ":" + m.cfg.Password
|
||||
}
|
||||
|
||||
|
||||
dsn += "@"
|
||||
|
||||
|
||||
if m.cfg.Host != "" && m.cfg.Host != "localhost" {
|
||||
dsn += "tcp(" + m.cfg.Host + ":" + strconv.Itoa(m.cfg.Port) + ")"
|
||||
}
|
||||
|
||||
|
||||
dsn += "/" + m.cfg.Database
|
||||
|
||||
|
||||
// Add connection parameters
|
||||
params := []string{}
|
||||
|
||||
if m.cfg.Insecure {
|
||||
params = append(params, "tls=skip-verify")
|
||||
} else if m.cfg.SSLMode != "" {
|
||||
|
||||
if !m.cfg.Insecure {
|
||||
switch strings.ToLower(m.cfg.SSLMode) {
|
||||
case "disable", "disabled":
|
||||
params = append(params, "tls=false")
|
||||
case "require", "required":
|
||||
params = append(params, "tls=true")
|
||||
case "verify-ca", "verify-full", "verify-identity":
|
||||
params = append(params, "tls=preferred")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add charset
|
||||
params = append(params, "charset=utf8mb4")
|
||||
params = append(params, "parseTime=true")
|
||||
|
||||
|
||||
if len(params) > 0 {
|
||||
dsn += "?" + strings.Join(params, "&")
|
||||
}
|
||||
|
||||
|
||||
return dsn
|
||||
}
|
||||
|
||||
@ -407,4 +402,4 @@ func sanitizeMySQLDSN(dsn string) string {
|
||||
}
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@ -23,8 +24,8 @@ var (
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
|
||||
menuSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF75B7")).
|
||||
Bold(true)
|
||||
Foreground(lipgloss.Color("#FF75B7")).
|
||||
Bold(true)
|
||||
|
||||
infoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
@ -36,17 +37,28 @@ var (
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF6B6B")).
|
||||
Bold(true)
|
||||
|
||||
dbSelectorLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#57C7FF")).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
type dbTypeOption struct {
|
||||
label string
|
||||
value string
|
||||
}
|
||||
|
||||
// MenuModel represents the simple menu state
|
||||
type MenuModel struct {
|
||||
choices []string
|
||||
cursor int
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
quitting bool
|
||||
message string
|
||||
|
||||
choices []string
|
||||
cursor int
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
quitting bool
|
||||
message string
|
||||
dbTypes []dbTypeOption
|
||||
dbTypeCursor int
|
||||
|
||||
// Background operations
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@ -54,7 +66,17 @@ type MenuModel struct {
|
||||
|
||||
func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
||||
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{
|
||||
choices: []string{
|
||||
"Single Database Backup",
|
||||
@ -67,12 +89,14 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
||||
"Clear Operation History",
|
||||
"Quit",
|
||||
},
|
||||
config: cfg,
|
||||
logger: log,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
config: cfg,
|
||||
logger: log,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
dbTypes: dbTypes,
|
||||
dbTypeCursor: dbCursor,
|
||||
}
|
||||
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
@ -93,6 +117,24 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.quitting = true
|
||||
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":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
@ -146,9 +188,24 @@ func (m MenuModel) View() string {
|
||||
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu")
|
||||
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
|
||||
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
||||
m.config.User, m.config.Host, m.config.Port, m.config.DatabaseType))
|
||||
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
||||
m.config.User, m.config.Host, m.config.Port, m.config.DisplayDatabaseType()))
|
||||
s += fmt.Sprintf("%s\n\n", dbInfo)
|
||||
|
||||
// Menu items
|
||||
@ -189,6 +246,10 @@ func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
|
||||
|
||||
// handleClusterBackup shows confirmation and executes cluster backup
|
||||
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,
|
||||
"🗄️ Cluster Backup",
|
||||
"This will backup ALL databases in the cluster. Continue?")
|
||||
@ -220,14 +281,39 @@ func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) {
|
||||
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
|
||||
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
|
||||
m := NewMenuModel(cfg, log)
|
||||
p := tea.NewProgram(m)
|
||||
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running interactive menu: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,11 +14,11 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")).Padding(1, 2)
|
||||
inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||
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)
|
||||
detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
|
||||
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")).Padding(1, 2)
|
||||
inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||
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)
|
||||
detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
|
||||
)
|
||||
|
||||
// SettingsModel represents the settings configuration state
|
||||
@ -50,6 +50,16 @@ type SettingItem struct {
|
||||
// Initialize settings model
|
||||
func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) SettingsModel {
|
||||
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",
|
||||
DisplayName: "Backup Directory",
|
||||
@ -195,8 +205,12 @@ func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) S
|
||||
{
|
||||
Key: "auto_detect_cores",
|
||||
DisplayName: "Auto Detect CPU Cores",
|
||||
Value: func(c *config.Config) string {
|
||||
if c.AutoDetectCores { return "true" } else { return "false" }
|
||||
Value: func(c *config.Config) string {
|
||||
if c.AutoDetectCores {
|
||||
return "true"
|
||||
} else {
|
||||
return "false"
|
||||
}
|
||||
},
|
||||
Update: func(c *config.Config, v string) error {
|
||||
val, err := strconv.ParseBool(v)
|
||||
@ -274,11 +288,11 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
if m.editing {
|
||||
return m.handleEditingInput(msg)
|
||||
}
|
||||
|
||||
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc":
|
||||
m.quitting = true
|
||||
@ -328,29 +342,29 @@ func (m SettingsModel) handleEditingInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case "ctrl+c":
|
||||
m.quitting = true
|
||||
return m.parent, nil
|
||||
|
||||
|
||||
case "esc":
|
||||
m.editing = false
|
||||
m.editingField = ""
|
||||
m.editingValue = ""
|
||||
m.message = ""
|
||||
return m, nil
|
||||
|
||||
|
||||
case "enter":
|
||||
return m.saveEditedValue()
|
||||
|
||||
|
||||
case "backspace":
|
||||
if len(m.editingValue) > 0 {
|
||||
m.editingValue = m.editingValue[:len(m.editingValue)-1]
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
// Add character to editing value
|
||||
if len(msg.String()) == 1 {
|
||||
m.editingValue += msg.String()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@ -359,13 +373,13 @@ func (m SettingsModel) startEditing() (tea.Model, tea.Cmd) {
|
||||
if m.cursor >= len(m.settings) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
setting := m.settings[m.cursor]
|
||||
m.editing = true
|
||||
m.editingField = setting.Key
|
||||
m.editingValue = setting.Value(m.config)
|
||||
m.message = ""
|
||||
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@ -374,7 +388,7 @@ func (m SettingsModel) saveEditedValue() (tea.Model, tea.Cmd) {
|
||||
if m.editingField == "" {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
// Find the setting being edited
|
||||
var setting *SettingItem
|
||||
for i := range m.settings {
|
||||
@ -383,41 +397,41 @@ func (m SettingsModel) saveEditedValue() (tea.Model, tea.Cmd) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if setting == nil {
|
||||
m.message = errorStyle.Render("❌ Setting not found")
|
||||
m.editing = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
// Update the configuration
|
||||
if err := setting.Update(m.config, m.editingValue); err != nil {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ %s", err.Error()))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
m.message = successStyle.Render(fmt.Sprintf("✅ Updated %s", setting.DisplayName))
|
||||
m.editing = false
|
||||
m.editingField = ""
|
||||
m.editingValue = ""
|
||||
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// resetToDefaults resets configuration to default values
|
||||
func (m SettingsModel) resetToDefaults() (tea.Model, tea.Cmd) {
|
||||
newConfig := config.New()
|
||||
|
||||
|
||||
// Copy important connection details
|
||||
newConfig.Host = m.config.Host
|
||||
newConfig.Port = m.config.Port
|
||||
newConfig.User = m.config.User
|
||||
newConfig.Database = m.config.Database
|
||||
newConfig.DatabaseType = m.config.DatabaseType
|
||||
|
||||
|
||||
*m.config = *newConfig
|
||||
m.message = successStyle.Render("✅ Settings reset to defaults")
|
||||
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@ -427,7 +441,7 @@ func (m SettingsModel) saveSettings() (tea.Model, tea.Cmd) {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ Validation failed: %s", err.Error()))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
||||
// Optimize CPU settings if auto-detect is enabled
|
||||
if m.config.AutoDetectCores {
|
||||
if err := m.config.OptimizeForCPU(); err != nil {
|
||||
@ -435,7 +449,7 @@ func (m SettingsModel) saveSettings() (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
m.message = successStyle.Render("✅ Settings validated and saved")
|
||||
return m, nil
|
||||
}
|
||||
@ -456,7 +470,11 @@ func (m SettingsModel) View() string {
|
||||
for i, setting := range m.settings {
|
||||
cursor := " "
|
||||
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 {
|
||||
cursor = ">"
|
||||
if m.editing && m.editingField == setting.Key {
|
||||
@ -469,22 +487,22 @@ func (m SettingsModel) View() string {
|
||||
b.WriteString(selectedStyle.Render(line))
|
||||
b.WriteString(" ✏️")
|
||||
} 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))
|
||||
}
|
||||
} 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("\n")
|
||||
|
||||
|
||||
// Show description for selected item
|
||||
if m.cursor == i && !m.editing {
|
||||
desc := detailStyle.Render(fmt.Sprintf(" %s", setting.Description))
|
||||
b.WriteString(desc)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
|
||||
// Show directory browser for current path field
|
||||
if m.cursor == i && m.browsingDir && m.dirBrowser != nil && setting.Type == "path" {
|
||||
b.WriteString("\n")
|
||||
@ -506,14 +524,15 @@ func (m SettingsModel) View() string {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(infoStyle.Render("📋 Current Configuration:"))
|
||||
b.WriteString("\n")
|
||||
|
||||
|
||||
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("Backup Dir: %s", m.config.BackupDir),
|
||||
fmt.Sprintf("Compression: Level %d", m.config.CompressionLevel),
|
||||
fmt.Sprintf("Jobs: %d parallel, %d dump", m.config.Jobs, m.config.DumpJobs),
|
||||
}
|
||||
|
||||
|
||||
for _, line := range summary {
|
||||
b.WriteString(detailStyle.Render(fmt.Sprintf(" %s", line)))
|
||||
b.WriteString("\n")
|
||||
@ -559,7 +578,7 @@ func (m SettingsModel) openDirectoryBrowser() (tea.Model, tea.Cmd) {
|
||||
m.dirBrowser.CurrentPath = currentValue
|
||||
m.dirBrowser.LoadItems()
|
||||
}
|
||||
|
||||
|
||||
m.dirBrowser.Show()
|
||||
m.browsingDir = true
|
||||
|
||||
@ -570,10 +589,10 @@ func (m SettingsModel) openDirectoryBrowser() (tea.Model, tea.Cmd) {
|
||||
func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error {
|
||||
m := NewSettingsModel(cfg, log, parent)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running settings menu: %w", err)
|
||||
}
|
||||
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,7 +148,7 @@ func (m StatusViewModel) View() string {
|
||||
}
|
||||
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("User: %s\n", m.config.User))
|
||||
s.WriteString(fmt.Sprintf("Backup Directory: %s\n", m.config.BackupDir))
|
||||
|
||||
Reference in New Issue
Block a user