Some checks failed
CI/CD / Test (push) Successful in 1m20s
CI/CD / Lint (push) Successful in 1m11s
CI/CD / Integration Tests (push) Successful in 52s
CI/CD / Native Engine Tests (push) Successful in 48s
CI/CD / Build Binary (push) Successful in 45s
CI/CD / Test Release Build (push) Successful in 1m19s
CI/CD / Release Binaries (push) Has been cancelled
Added 'dbbackup cloud status' command for cloud storage health checks: Features: - Cloud provider configuration validation - Authentication/credentials testing - Bucket/container existence verification - List permissions check (read access) - Upload/delete permissions test (write access) - Network connectivity testing - Latency/performance measurements - Storage usage statistics Supports: - AWS S3 - Google Cloud Storage (GCS) - Azure Blob Storage - MinIO - Backblaze B2 Usage Examples: dbbackup cloud status # Full check dbbackup cloud status --quick # Skip upload test dbbackup cloud status --verbose # Show detailed info dbbackup cloud status --format json # JSON output Validation Checks: ✓ Configuration (provider, bucket) ✓ Initialize connection ✓ Bucket access ✓ List objects (read permissions) ✓ Upload test file (write permissions) ✓ Delete test file (cleanup) Helps diagnose cloud storage issues before critical operations, preventing backup/restore failures due to connectivity or permission problems. Quick Win #7: Cloud Status - 25 min implementation
541 lines
15 KiB
Go
541 lines
15 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"dbbackup/internal/config"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var validateCmd = &cobra.Command{
|
|
Use: "validate",
|
|
Short: "Validate configuration and environment",
|
|
Long: `Validate dbbackup configuration file and runtime environment.
|
|
|
|
This command performs comprehensive validation:
|
|
- Configuration file syntax and structure
|
|
- Database connection parameters
|
|
- Directory paths and permissions
|
|
- External tool availability (pg_dump, mysqldump)
|
|
- Cloud storage credentials (if configured)
|
|
- Encryption setup (if enabled)
|
|
- Resource limits and system requirements
|
|
- Port accessibility
|
|
|
|
Helps identify configuration issues before running backups.
|
|
|
|
Examples:
|
|
# Validate default config (.dbbackup.conf)
|
|
dbbackup validate
|
|
|
|
# Validate specific config file
|
|
dbbackup validate --config /etc/dbbackup/prod.conf
|
|
|
|
# Quick validation (skip connectivity tests)
|
|
dbbackup validate --quick
|
|
|
|
# JSON output for automation
|
|
dbbackup validate --format json`,
|
|
RunE: runValidate,
|
|
}
|
|
|
|
var (
|
|
validateFormat string
|
|
validateQuick bool
|
|
)
|
|
|
|
type ValidationResult struct {
|
|
Valid bool `json:"valid"`
|
|
Issues []ValidationIssue `json:"issues"`
|
|
Warnings []ValidationIssue `json:"warnings"`
|
|
Checks []ValidationCheck `json:"checks"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
|
|
type ValidationIssue struct {
|
|
Category string `json:"category"`
|
|
Description string `json:"description"`
|
|
Suggestion string `json:"suggestion,omitempty"`
|
|
}
|
|
|
|
type ValidationCheck struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"` // "pass", "warn", "fail"
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
func init() {
|
|
rootCmd.AddCommand(validateCmd)
|
|
|
|
validateCmd.Flags().StringVar(&validateFormat, "format", "table", "Output format (table, json)")
|
|
validateCmd.Flags().BoolVar(&validateQuick, "quick", false, "Quick validation (skip connectivity tests)")
|
|
}
|
|
|
|
func runValidate(cmd *cobra.Command, args []string) error {
|
|
result := &ValidationResult{
|
|
Valid: true,
|
|
Issues: []ValidationIssue{},
|
|
Warnings: []ValidationIssue{},
|
|
Checks: []ValidationCheck{},
|
|
}
|
|
|
|
// Validate configuration file
|
|
validateConfigFile(cfg, result)
|
|
|
|
// Validate database settings
|
|
validateDatabase(cfg, result)
|
|
|
|
// Validate paths
|
|
validatePaths(cfg, result)
|
|
|
|
// Validate external tools
|
|
validateTools(cfg, result)
|
|
|
|
// Validate cloud storage (if enabled)
|
|
if cfg.CloudEnabled {
|
|
validateCloud(cfg, result)
|
|
}
|
|
|
|
// Validate encryption (if enabled)
|
|
if cfg.PITREnabled && cfg.WALEncryption {
|
|
validateEncryption(cfg, result)
|
|
}
|
|
|
|
// Validate resource limits
|
|
validateResources(cfg, result)
|
|
|
|
// Connectivity tests (unless --quick)
|
|
if !validateQuick {
|
|
validateConnectivity(cfg, result)
|
|
}
|
|
|
|
// Determine overall validity
|
|
result.Valid = len(result.Issues) == 0
|
|
|
|
// Generate summary
|
|
if result.Valid {
|
|
if len(result.Warnings) > 0 {
|
|
result.Summary = fmt.Sprintf("Configuration valid with %d warning(s)", len(result.Warnings))
|
|
} else {
|
|
result.Summary = "Configuration valid - all checks passed"
|
|
}
|
|
} else {
|
|
result.Summary = fmt.Sprintf("Configuration invalid - %d issue(s) found", len(result.Issues))
|
|
}
|
|
|
|
// Output results
|
|
if validateFormat == "json" {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(result)
|
|
}
|
|
|
|
printValidationResult(result)
|
|
|
|
if !result.Valid {
|
|
return fmt.Errorf("validation failed")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateConfigFile(cfg *config.Config, result *ValidationResult) {
|
|
check := ValidationCheck{Name: "Configuration File"}
|
|
|
|
if cfg.ConfigPath == "" {
|
|
check.Status = "warn"
|
|
check.Message = "No config file specified (using defaults)"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "config",
|
|
Description: "No configuration file found",
|
|
Suggestion: "Run 'dbbackup backup' to create .dbbackup.conf",
|
|
})
|
|
} else {
|
|
if _, err := os.Stat(cfg.ConfigPath); err != nil {
|
|
check.Status = "warn"
|
|
check.Message = "Config file not found"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "config",
|
|
Description: fmt.Sprintf("Config file not accessible: %s", cfg.ConfigPath),
|
|
Suggestion: "Check file path and permissions",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = fmt.Sprintf("Loaded from %s", cfg.ConfigPath)
|
|
}
|
|
}
|
|
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func validateDatabase(cfg *config.Config, result *ValidationResult) {
|
|
// Database type
|
|
check := ValidationCheck{Name: "Database Type"}
|
|
if cfg.DatabaseType != "postgres" && cfg.DatabaseType != "mysql" && cfg.DatabaseType != "mariadb" {
|
|
check.Status = "fail"
|
|
check.Message = fmt.Sprintf("Invalid: %s", cfg.DatabaseType)
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "database",
|
|
Description: fmt.Sprintf("Invalid database type: %s", cfg.DatabaseType),
|
|
Suggestion: "Use 'postgres', 'mysql', or 'mariadb'",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.DatabaseType
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// Host
|
|
check = ValidationCheck{Name: "Database Host"}
|
|
if cfg.Host == "" {
|
|
check.Status = "fail"
|
|
check.Message = "Not configured"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "database",
|
|
Description: "Database host not specified",
|
|
Suggestion: "Set --host flag or host in config file",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.Host
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// Port
|
|
check = ValidationCheck{Name: "Database Port"}
|
|
if cfg.Port <= 0 || cfg.Port > 65535 {
|
|
check.Status = "fail"
|
|
check.Message = fmt.Sprintf("Invalid: %d", cfg.Port)
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "database",
|
|
Description: fmt.Sprintf("Invalid port number: %d", cfg.Port),
|
|
Suggestion: "Use valid port (1-65535)",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = strconv.Itoa(cfg.Port)
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// User
|
|
check = ValidationCheck{Name: "Database User"}
|
|
if cfg.User == "" {
|
|
check.Status = "warn"
|
|
check.Message = "Not configured (using current user)"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "database",
|
|
Description: "Database user not specified",
|
|
Suggestion: "Set --user flag or user in config file",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.User
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func validatePaths(cfg *config.Config, result *ValidationResult) {
|
|
// Backup directory
|
|
check := ValidationCheck{Name: "Backup Directory"}
|
|
if cfg.BackupDir == "" {
|
|
check.Status = "fail"
|
|
check.Message = "Not configured"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "paths",
|
|
Description: "Backup directory not specified",
|
|
Suggestion: "Set --backup-dir flag or backup_dir in config",
|
|
})
|
|
} else {
|
|
info, err := os.Stat(cfg.BackupDir)
|
|
if err != nil {
|
|
check.Status = "warn"
|
|
check.Message = "Does not exist (will be created)"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "paths",
|
|
Description: fmt.Sprintf("Backup directory does not exist: %s", cfg.BackupDir),
|
|
Suggestion: "Directory will be created automatically",
|
|
})
|
|
} else if !info.IsDir() {
|
|
check.Status = "fail"
|
|
check.Message = "Not a directory"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "paths",
|
|
Description: fmt.Sprintf("Backup path is not a directory: %s", cfg.BackupDir),
|
|
Suggestion: "Specify a valid directory path",
|
|
})
|
|
} else {
|
|
// Check write permissions
|
|
testFile := filepath.Join(cfg.BackupDir, ".dbbackup-test")
|
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
|
check.Status = "fail"
|
|
check.Message = "Not writable"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "paths",
|
|
Description: fmt.Sprintf("Cannot write to backup directory: %s", cfg.BackupDir),
|
|
Suggestion: "Check directory permissions",
|
|
})
|
|
} else {
|
|
os.Remove(testFile)
|
|
check.Status = "pass"
|
|
check.Message = cfg.BackupDir
|
|
}
|
|
}
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// WAL archive directory (if PITR enabled)
|
|
if cfg.PITREnabled {
|
|
check = ValidationCheck{Name: "WAL Archive Directory"}
|
|
if cfg.WALArchiveDir == "" {
|
|
check.Status = "warn"
|
|
check.Message = "Not configured"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "pitr",
|
|
Description: "PITR enabled but WAL archive directory not set",
|
|
Suggestion: "Set --wal-archive-dir for PITR functionality",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.WALArchiveDir
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
}
|
|
|
|
func validateTools(cfg *config.Config, result *ValidationResult) {
|
|
// Skip if using native engine
|
|
if cfg.UseNativeEngine {
|
|
check := ValidationCheck{
|
|
Name: "External Tools",
|
|
Status: "pass",
|
|
Message: "Using native Go engine (no external tools required)",
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
return
|
|
}
|
|
|
|
// Check for database tools
|
|
var requiredTools []string
|
|
if cfg.DatabaseType == "postgres" {
|
|
requiredTools = []string{"pg_dump", "pg_restore", "psql"}
|
|
} else if cfg.DatabaseType == "mysql" || cfg.DatabaseType == "mariadb" {
|
|
requiredTools = []string{"mysqldump", "mysql"}
|
|
}
|
|
|
|
for _, tool := range requiredTools {
|
|
check := ValidationCheck{Name: fmt.Sprintf("Tool: %s", tool)}
|
|
path, err := exec.LookPath(tool)
|
|
if err != nil {
|
|
check.Status = "fail"
|
|
check.Message = "Not found in PATH"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "tools",
|
|
Description: fmt.Sprintf("Required tool not found: %s", tool),
|
|
Suggestion: fmt.Sprintf("Install %s or use --native flag", tool),
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = path
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
}
|
|
|
|
func validateCloud(cfg *config.Config, result *ValidationResult) {
|
|
check := ValidationCheck{Name: "Cloud Storage"}
|
|
|
|
if cfg.CloudProvider == "" {
|
|
check.Status = "fail"
|
|
check.Message = "Provider not configured"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "cloud",
|
|
Description: "Cloud enabled but provider not specified",
|
|
Suggestion: "Set --cloud-provider (s3, gcs, azure, minio, b2)",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.CloudProvider
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// Bucket
|
|
check = ValidationCheck{Name: "Cloud Bucket"}
|
|
if cfg.CloudBucket == "" {
|
|
check.Status = "fail"
|
|
check.Message = "Not configured"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "cloud",
|
|
Description: "Cloud bucket/container not specified",
|
|
Suggestion: "Set --cloud-bucket",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = cfg.CloudBucket
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// Credentials
|
|
check = ValidationCheck{Name: "Cloud Credentials"}
|
|
if cfg.CloudAccessKey == "" || cfg.CloudSecretKey == "" {
|
|
check.Status = "warn"
|
|
check.Message = "Credentials not in config (may use env vars)"
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "cloud",
|
|
Description: "Cloud credentials not in config file",
|
|
Suggestion: "Ensure AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or similar env vars are set",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = "Configured"
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func validateEncryption(cfg *config.Config, result *ValidationResult) {
|
|
check := ValidationCheck{Name: "Encryption"}
|
|
|
|
// Check for openssl
|
|
if _, err := exec.LookPath("openssl"); err != nil {
|
|
check.Status = "fail"
|
|
check.Message = "openssl not found"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "encryption",
|
|
Description: "Encryption enabled but openssl not available",
|
|
Suggestion: "Install openssl or disable WAL encryption",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = "openssl available"
|
|
}
|
|
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func validateResources(cfg *config.Config, result *ValidationResult) {
|
|
// CPU cores
|
|
check := ValidationCheck{Name: "CPU Cores"}
|
|
if cfg.MaxCores < 1 {
|
|
check.Status = "fail"
|
|
check.Message = "Invalid core count"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "resources",
|
|
Description: "Invalid max cores setting",
|
|
Suggestion: "Set --max-cores to positive value",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = fmt.Sprintf("%d cores", cfg.MaxCores)
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
|
|
// Jobs
|
|
check = ValidationCheck{Name: "Parallel Jobs"}
|
|
if cfg.Jobs < 1 {
|
|
check.Status = "fail"
|
|
check.Message = "Invalid job count"
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "resources",
|
|
Description: "Invalid jobs setting",
|
|
Suggestion: "Set --jobs to positive value",
|
|
})
|
|
} else if cfg.Jobs > cfg.MaxCores*2 {
|
|
check.Status = "warn"
|
|
check.Message = fmt.Sprintf("%d jobs (high)", cfg.Jobs)
|
|
result.Warnings = append(result.Warnings, ValidationIssue{
|
|
Category: "resources",
|
|
Description: "Jobs count higher than CPU cores",
|
|
Suggestion: "Consider reducing --jobs for better performance",
|
|
})
|
|
} else {
|
|
check.Status = "pass"
|
|
check.Message = fmt.Sprintf("%d jobs", cfg.Jobs)
|
|
}
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func validateConnectivity(cfg *config.Config, result *ValidationResult) {
|
|
check := ValidationCheck{Name: "Database Connectivity"}
|
|
|
|
// Try to connect to database port
|
|
address := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
|
conn, err := net.DialTimeout("tcp", address, 5*1000000000) // 5 seconds
|
|
if err != nil {
|
|
check.Status = "fail"
|
|
check.Message = fmt.Sprintf("Cannot connect to %s", address)
|
|
result.Issues = append(result.Issues, ValidationIssue{
|
|
Category: "connectivity",
|
|
Description: fmt.Sprintf("Cannot connect to database: %v", err),
|
|
Suggestion: "Check host, port, and network connectivity",
|
|
})
|
|
} else {
|
|
conn.Close()
|
|
check.Status = "pass"
|
|
check.Message = fmt.Sprintf("Connected to %s", address)
|
|
}
|
|
|
|
result.Checks = append(result.Checks, check)
|
|
}
|
|
|
|
func printValidationResult(result *ValidationResult) {
|
|
fmt.Println("\n[VALIDATION REPORT]")
|
|
fmt.Println(strings.Repeat("=", 60))
|
|
|
|
// Print checks
|
|
fmt.Println("\n[CHECKS]")
|
|
for _, check := range result.Checks {
|
|
var status string
|
|
switch check.Status {
|
|
case "pass":
|
|
status = "[PASS]"
|
|
case "warn":
|
|
status = "[WARN]"
|
|
case "fail":
|
|
status = "[FAIL]"
|
|
}
|
|
|
|
fmt.Printf(" %-25s %s", check.Name+":", status)
|
|
if check.Message != "" {
|
|
fmt.Printf(" %s", check.Message)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
// Print issues
|
|
if len(result.Issues) > 0 {
|
|
fmt.Println("\n[ISSUES]")
|
|
for i, issue := range result.Issues {
|
|
fmt.Printf(" %d. [%s] %s\n", i+1, strings.ToUpper(issue.Category), issue.Description)
|
|
if issue.Suggestion != "" {
|
|
fmt.Printf(" → %s\n", issue.Suggestion)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print warnings
|
|
if len(result.Warnings) > 0 {
|
|
fmt.Println("\n[WARNINGS]")
|
|
for i, warning := range result.Warnings {
|
|
fmt.Printf(" %d. [%s] %s\n", i+1, strings.ToUpper(warning.Category), warning.Description)
|
|
if warning.Suggestion != "" {
|
|
fmt.Printf(" → %s\n", warning.Suggestion)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print summary
|
|
fmt.Println("\n" + strings.Repeat("=", 60))
|
|
if result.Valid {
|
|
fmt.Printf("[OK] %s\n\n", result.Summary)
|
|
} else {
|
|
fmt.Printf("[FAIL] %s\n\n", result.Summary)
|
|
}
|
|
}
|