Files
wild-cloud/api/internal/backup/strategies/postgres.go

473 lines
15 KiB
Go

package strategies
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"strings"
"time"
"github.com/wild-cloud/wild-central/daemon/internal/apps"
btypes "github.com/wild-cloud/wild-central/daemon/internal/backup/types"
"github.com/wild-cloud/wild-central/daemon/internal/tools"
"gopkg.in/yaml.v3"
)
// PostgreSQLStrategy implements backup strategy for PostgreSQL databases
type PostgreSQLStrategy struct {
dataDir string
}
// NewPostgreSQLStrategy creates a new PostgreSQL backup strategy
func NewPostgreSQLStrategy(dataDir string) *PostgreSQLStrategy {
return &PostgreSQLStrategy{
dataDir: dataDir,
}
}
// Name returns the strategy identifier
func (p *PostgreSQLStrategy) Name() string {
return "postgres"
}
// Backup creates a PostgreSQL database backup using direct streaming
func (p *PostgreSQLStrategy) Backup(instanceName, appName string, manifest *apps.AppManifest, dest btypes.BackupDestination) (*btypes.ComponentBackup, error) {
kubeconfigPath := tools.GetKubeconfigPath(p.dataDir, instanceName)
// Determine database name from manifest or default to app name
dbName := p.getDatabaseName(instanceName, appName)
timestamp := time.Now().Format("20060102-150405")
key := fmt.Sprintf("postgres/%s/%s/%s.dump", instanceName, appName, timestamp)
// Get the postgres pod name
podName, err := p.getPostgresPod(kubeconfigPath)
if err != nil {
return nil, fmt.Errorf("failed to find postgres pod: %w", err)
}
// Create pg_dump command that streams to stdout
cmd := exec.Command("kubectl", "exec", "-n", "postgres",
podName, "--", "pg_dump",
"-U", "postgres",
"--format=custom",
"--no-owner",
"--compress=9",
"--dbname", dbName,
)
tools.WithKubeconfig(cmd, kubeconfigPath)
// Use io.Pipe to stream directly from pg_dump to destination
reader, writer := io.Pipe()
// Capture stderr for error messages
var stderr bytes.Buffer
cmd.Stdout = writer
cmd.Stderr = &stderr
// Start pg_dump in goroutine
errChan := make(chan error, 1)
go func() {
defer writer.Close()
err := cmd.Run()
if err != nil {
errChan <- fmt.Errorf("pg_dump failed: %v, stderr: %s", err, stderr.String())
} else {
errChan <- nil
}
}()
// Stream to destination
size, err := dest.Put(key, reader)
if err != nil {
cmd.Process.Kill() // Stop pg_dump if upload fails
return nil, fmt.Errorf("failed to upload backup: %w", err)
}
// Wait for pg_dump to complete
if pgErr := <-errChan; pgErr != nil {
return nil, pgErr
}
// Also backup globals (users, roles, etc)
globalsKey := fmt.Sprintf("postgres/%s/%s/%s-globals.sql", instanceName, appName, timestamp)
if err := p.backupGlobals(kubeconfigPath, dest, globalsKey); err != nil {
// Globals backup is optional, log but don't fail
fmt.Printf("Warning: failed to backup PostgreSQL globals: %v\n", err)
}
return &btypes.ComponentBackup{
Type: "postgres",
Name: fmt.Sprintf("postgres.%s", dbName),
Size: size,
Location: key,
Metadata: map[string]interface{}{
"database": dbName,
"format": "custom",
"globals": globalsKey,
},
}, nil
}
// Restore restores a PostgreSQL database from backup
func (p *PostgreSQLStrategy) Restore(component *btypes.ComponentBackup, dest btypes.BackupDestination) error {
// Get instance and app name from component location
// Format: postgres/{instance}/{app}/{timestamp}.dump
parts := strings.Split(component.Location, "/")
if len(parts) < 3 {
return fmt.Errorf("invalid backup location format")
}
instanceName := parts[1]
appName := parts[2]
kubeconfigPath := tools.GetKubeconfigPath(p.dataDir, instanceName)
dbName, _ := component.Metadata["database"].(string)
if dbName == "" {
return fmt.Errorf("database name not found in backup metadata")
}
// For blue-green restore, create a restore database alongside production
restoreDbName := fmt.Sprintf("%s_restore", dbName)
// Check if this is a restore operation (blue-green)
isBlueGreen := component.Metadata["blueGreen"] == true
// Debug logging
fmt.Printf("PostgreSQL Restore: dbName=%s, restoreDbName=%s, isBlueGreen=%v, metadata=%+v\n",
dbName, restoreDbName, isBlueGreen, component.Metadata)
// Get the postgres pod name
podName, err := p.getPostgresPod(kubeconfigPath)
if err != nil {
return fmt.Errorf("failed to find postgres pod: %w", err)
}
// Get backup from destination
reader, err := dest.Get(component.Location)
if err != nil {
return fmt.Errorf("failed to retrieve backup: %w", err)
}
defer reader.Close()
targetDb := dbName
if isBlueGreen {
targetDb = restoreDbName
}
// In blue-green mode, we're working with a new database, so drop it if it exists
// In non-blue-green mode, we need to terminate connections first
if !isBlueGreen {
// Terminate all connections to the production database
terminateCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", "postgres", "-c",
fmt.Sprintf("SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid()", targetDb))
tools.WithKubeconfig(terminateCmd, kubeconfigPath)
if output, err := terminateCmd.CombinedOutput(); err != nil {
// Non-critical if no connections exist, continue
fmt.Printf("Warning: failed to terminate connections: %v, output: %s\n", err, output)
}
}
// Drop target database if it exists
dropCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", "postgres", "-c",
fmt.Sprintf("DROP DATABASE IF EXISTS %s", targetDb))
tools.WithKubeconfig(dropCmd, kubeconfigPath)
if output, err := dropCmd.CombinedOutput(); err != nil {
// If blue-green and can't drop restore db, it's okay to continue
if !isBlueGreen {
return fmt.Errorf("failed to drop database: %w, output: %s", err, output)
}
fmt.Printf("Warning: failed to drop restore database: %v\n", err)
}
// Create target database
createCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", "postgres", "-c",
fmt.Sprintf("CREATE DATABASE %s", targetDb))
tools.WithKubeconfig(createCmd, kubeconfigPath)
if output, err := createCmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create database: %w, output: %s", err, output)
}
// Grant permissions to the app user on the restore database
if isBlueGreen {
// Get the app user from config
appUser := p.getAppUser(instanceName, appName)
if appUser != "" && appUser != "postgres" {
grantCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", "postgres", "-c",
fmt.Sprintf("GRANT ALL PRIVILEGES ON DATABASE %s TO %s", targetDb, appUser))
tools.WithKubeconfig(grantCmd, kubeconfigPath)
if output, err := grantCmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to grant privileges: %v, output: %s\n", err, output)
}
}
}
// Restore database using pg_restore
restoreCmd := exec.Command("kubectl", "exec", "-i", "-n", "postgres", podName, "--",
"pg_restore", "-U", "postgres", "-d", targetDb, "--no-owner", "--clean", "--if-exists")
tools.WithKubeconfig(restoreCmd, kubeconfigPath)
restoreCmd.Stdin = reader
var stderr bytes.Buffer
restoreCmd.Stderr = &stderr
if err := restoreCmd.Run(); err != nil {
// pg_restore returns non-zero for warnings, check if database was actually restored
checkCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", targetDb, "-c", "\\dt")
tools.WithKubeconfig(checkCmd, kubeconfigPath)
if checkOutput, checkErr := checkCmd.Output(); checkErr != nil || !strings.Contains(string(checkOutput), "table") {
return fmt.Errorf("pg_restore failed: %w, stderr: %s", err, stderr.String())
}
// Restore succeeded with warnings, continue
}
// Grant table and sequence permissions after restore (for blue-green)
if isBlueGreen {
appUser := p.getAppUser(instanceName, appName)
if appUser != "" && appUser != "postgres" {
// Grant permissions on all tables in the restored database
grantTablesCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", targetDb, "-c",
fmt.Sprintf("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO %s", appUser))
tools.WithKubeconfig(grantTablesCmd, kubeconfigPath)
if output, err := grantTablesCmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to grant table privileges: %v, output: %s\n", err, output)
}
// Grant permissions on all sequences
grantSeqCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", targetDb, "-c",
fmt.Sprintf("GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO %s", appUser))
tools.WithKubeconfig(grantSeqCmd, kubeconfigPath)
if output, err := grantSeqCmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to grant sequence privileges: %v, output: %s\n", err, output)
}
// Also grant permissions on schema itself
grantSchemaCmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", targetDb, "-c",
fmt.Sprintf("GRANT ALL ON SCHEMA public TO %s", appUser))
tools.WithKubeconfig(grantSchemaCmd, kubeconfigPath)
if output, err := grantSchemaCmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to grant schema privileges: %v, output: %s\n", err, output)
}
}
}
// Restore globals if present (only for non-blue-green)
if !isBlueGreen {
if globalsKey, ok := component.Metadata["globals"].(string); ok && globalsKey != "" {
if err := p.restoreGlobals(kubeconfigPath, dest, globalsKey); err != nil {
// Globals restore is optional, log but don't fail
fmt.Printf("Warning: failed to restore PostgreSQL globals: %v\n", err)
}
}
}
return nil
}
// Verify checks if a PostgreSQL backup can be restored
func (p *PostgreSQLStrategy) Verify(component *btypes.ComponentBackup, dest btypes.BackupDestination) error {
// Check if backup exists in destination
reader, err := dest.Get(component.Location)
if err != nil {
return fmt.Errorf("backup not found in destination: %w", err)
}
reader.Close()
// Verify it's a valid pg_dump format by checking magic bytes
reader, err = dest.Get(component.Location)
if err != nil {
return err
}
defer reader.Close()
// pg_dump custom format starts with "PGDMP"
magic := make([]byte, 5)
if _, err := io.ReadFull(reader, magic); err != nil {
return fmt.Errorf("failed to read backup header: %w", err)
}
if string(magic) != "PGDMP" {
return fmt.Errorf("invalid PostgreSQL dump format")
}
return nil
}
// Supports checks if this strategy can handle the app based on its manifest
func (p *PostgreSQLStrategy) Supports(manifest *apps.AppManifest) bool {
// Check if the app has PostgreSQL in its dependencies
for _, dep := range manifest.Requires {
if dep.Name == "postgres" || dep.Name == "postgresql" || dep.Name == "pg" {
return true
}
}
return false
}
// backupGlobals backs up PostgreSQL global objects (users, roles, etc)
func (p *PostgreSQLStrategy) backupGlobals(kubeconfigPath string, dest btypes.BackupDestination, key string) error {
// Get the postgres pod name
podName, err := p.getPostgresPod(kubeconfigPath)
if err != nil {
return fmt.Errorf("failed to find postgres pod: %w", err)
}
cmd := exec.Command("kubectl", "exec", "-n", "postgres", podName, "--",
"pg_dumpall", "-U", "postgres", "--globals-only")
tools.WithKubeconfig(cmd, kubeconfigPath)
reader, writer := io.Pipe()
cmd.Stdout = writer
var stderr bytes.Buffer
cmd.Stderr = &stderr
errChan := make(chan error, 1)
go func() {
defer writer.Close()
if err := cmd.Run(); err != nil {
errChan <- fmt.Errorf("pg_dumpall failed: %v, stderr: %s", err, stderr.String())
} else {
errChan <- nil
}
}()
if _, err := dest.Put(key, reader); err != nil {
cmd.Process.Kill()
return err
}
return <-errChan
}
// restoreGlobals restores PostgreSQL global objects
func (p *PostgreSQLStrategy) restoreGlobals(kubeconfigPath string, dest btypes.BackupDestination, key string) error {
// Get the postgres pod name
podName, err := p.getPostgresPod(kubeconfigPath)
if err != nil {
return fmt.Errorf("failed to find postgres pod: %w", err)
}
reader, err := dest.Get(key)
if err != nil {
return fmt.Errorf("failed to retrieve globals backup: %w", err)
}
defer reader.Close()
cmd := exec.Command("kubectl", "exec", "-i", "-n", "postgres", podName, "--",
"psql", "-U", "postgres", "-d", "postgres")
tools.WithKubeconfig(cmd, kubeconfigPath)
cmd.Stdin = reader
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restore globals: %w, stderr: %s", err, stderr.String())
}
return nil
}
// getDatabaseName determines the database name for the app
func (p *PostgreSQLStrategy) getDatabaseName(instanceName, appName string) string {
// Read from config.yaml to get the actual database name
configPath := tools.GetInstanceConfigPath(p.dataDir, instanceName)
data, err := os.ReadFile(configPath)
if err != nil {
// Fall back to app name if config can't be read
return appName
}
var config map[string]interface{}
if err := yaml.Unmarshal(data, &config); err != nil {
// Fall back to app name if config can't be parsed
return appName
}
// Extract app-specific configuration
if apps, ok := config["apps"].(map[string]interface{}); ok {
if appConfig, ok := apps[appName].(map[string]interface{}); ok {
// Look for dbName field
if dbName, ok := appConfig["dbName"].(string); ok && dbName != "" {
return dbName
}
}
}
// Fall back to app name if dbName not found
return appName
}
// getAppUser retrieves the database user for the app from config
func (p *PostgreSQLStrategy) getAppUser(instanceName, appName string) string {
configPath := tools.GetInstanceConfigPath(p.dataDir, instanceName)
data, err := os.ReadFile(configPath)
if err != nil {
return ""
}
var config map[string]interface{}
if err := yaml.Unmarshal(data, &config); err != nil {
return ""
}
// Extract app-specific configuration
if apps, ok := config["apps"].(map[string]interface{}); ok {
if appConfig, ok := apps[appName].(map[string]interface{}); ok {
// Look for dbUser or dbUsername field
if dbUser, ok := appConfig["dbUser"].(string); ok && dbUser != "" {
return dbUser
}
if dbUsername, ok := appConfig["dbUsername"].(string); ok && dbUsername != "" {
return dbUsername
}
}
}
// Default to app name as user
return appName
}
// getPostgresPod finds the first running postgres pod
func (p *PostgreSQLStrategy) getPostgresPod(kubeconfigPath string) (string, error) {
cmd := exec.Command("kubectl", "get", "pods", "-n", "postgres",
"-l", "app=postgres", "-o", "jsonpath={.items[0].metadata.name}")
tools.WithKubeconfig(cmd, kubeconfigPath)
output, err := cmd.Output()
if err != nil {
// Try without label selector in case labels are different
cmd = exec.Command("kubectl", "get", "pods", "-n", "postgres",
"-o", "jsonpath={.items[0].metadata.name}")
tools.WithKubeconfig(cmd, kubeconfigPath)
output, err = cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to get postgres pod: %w", err)
}
}
podName := strings.TrimSpace(string(output))
if podName == "" {
return "", fmt.Errorf("no postgres pod found")
}
return podName, nil
}