feat: Week 3 Phase 1 - WAL Archiving & PITR Setup

## WAL Archiving Implementation (Phase 1/5)

### Core Components Created
-  internal/wal/archiver.go (280 lines)
  - WAL file archiving with timeline/segment parsing
  - Archive statistics and cleanup
  - Compression/encryption scaffolding (TODO)

-  internal/wal/pitr_config.go (360 lines)
  - PostgreSQL configuration management
  - auto-detects postgresql.conf location
  - Backs up config before modifications
  - Recovery configuration for PG 12+ and legacy

-  cmd/pitr.go (350 lines)
  - pitr enable/disable/status commands
  - wal archive/list/cleanup commands
  - Integrated with existing CLI

### Features Implemented
**WAL Archiving:**
- ParseWALFileName: Extract timeline + segment from WAL files
- ArchiveWALFile: Copy WAL to archive directory
- ListArchivedWALFiles: View all archived WAL segments
- CleanupOldWALFiles: Retention-based cleanup
- GetArchiveStats: Statistics (total size, file count, date range)

**PITR Configuration:**
- EnablePITR: Auto-configure postgresql.conf for PITR
  - Sets wal_level=replica, archive_mode=on
  - Configures archive_command to call dbbackup
  - Creates WAL archive directory
- DisablePITR: Turn off WAL archiving
- GetCurrentPITRConfig: Read current settings
- CreateRecoveryConf: Generate recovery config (PG 12+ & legacy)

**CLI Commands:**
```bash
# Enable PITR
dbbackup pitr enable --archive-dir /backups/wal_archive

# Check PITR status
dbbackup pitr status

# Archive WAL file (called by PostgreSQL)
dbbackup wal archive <path> <filename> --archive-dir /backups/wal

# List WAL archives
dbbackup wal list --archive-dir /backups/wal_archive

# Cleanup old WAL files
dbbackup wal cleanup --archive-dir /backups/wal_archive --retention-days 7
```

### Architecture
- Modular design: Separate archiver and PITR manager
- PostgreSQL version detection (12+ vs legacy)
- Automatic config file discovery
- Safe config modifications with backups

### Next Steps (Phase 2)
- [ ] Compression support (gzip)
- [ ] Encryption support (AES-256-GCM)
- [ ] Continuous WAL monitoring
- [ ] Timeline management
- [ ] Point-in-time restore command

Time: ~1.5h (3h estimated for Phase 1)
This commit is contained in:
2025-11-26 10:49:57 +00:00
parent 3ef57bb2f5
commit 8a1e2daa29
4 changed files with 1137 additions and 0 deletions

View File

@@ -76,6 +76,12 @@ type Config struct {
AllowRoot bool // Allow running as root/Administrator
CheckResources bool // Check resource limits before operations
// PITR (Point-in-Time Recovery) options
PITREnabled bool // Enable WAL archiving for PITR
WALArchiveDir string // Directory to store WAL archives
WALCompression bool // Compress WAL files
WALEncryption bool // Encrypt WAL files
// TUI automation options (for testing)
TUIAutoSelect int // Auto-select menu option (-1 = disabled)
TUIAutoDatabase string // Pre-fill database name

324
internal/wal/archiver.go Normal file
View File

@@ -0,0 +1,324 @@
package wal
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"dbbackup/internal/config"
"dbbackup/internal/logger"
)
// Archiver handles PostgreSQL Write-Ahead Log (WAL) archiving for PITR
type Archiver struct {
cfg *config.Config
log logger.Logger
}
// ArchiveConfig holds WAL archiving configuration
type ArchiveConfig struct {
ArchiveDir string // Directory to store archived WAL files
CompressWAL bool // Compress WAL files with gzip
EncryptWAL bool // Encrypt WAL files
RetentionDays int // Days to keep WAL archives
VerifyChecksum bool // Verify WAL file checksums
}
// WALArchiveInfo contains metadata about an archived WAL file
type WALArchiveInfo struct {
WALFileName string `json:"wal_filename"`
ArchivePath string `json:"archive_path"`
OriginalSize int64 `json:"original_size"`
ArchivedSize int64 `json:"archived_size"`
Checksum string `json:"checksum"`
Timeline uint32 `json:"timeline"`
Segment uint64 `json:"segment"`
ArchivedAt time.Time `json:"archived_at"`
Compressed bool `json:"compressed"`
Encrypted bool `json:"encrypted"`
}
// NewArchiver creates a new WAL archiver
func NewArchiver(cfg *config.Config, log logger.Logger) *Archiver {
return &Archiver{
cfg: cfg,
log: log,
}
}
// ArchiveWALFile archives a single WAL file to the archive directory
// This is called by PostgreSQL's archive_command
func (a *Archiver) ArchiveWALFile(ctx context.Context, walFilePath, walFileName string, config ArchiveConfig) (*WALArchiveInfo, error) {
a.log.Info("Archiving WAL file", "wal", walFileName, "source", walFilePath)
// Validate WAL file exists
stat, err := os.Stat(walFilePath)
if err != nil {
return nil, fmt.Errorf("WAL file not found: %s: %w", walFilePath, err)
}
// Ensure archive directory exists
if err := os.MkdirAll(config.ArchiveDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create WAL archive directory %s: %w", config.ArchiveDir, err)
}
// Parse WAL filename to extract timeline and segment
timeline, segment, err := ParseWALFileName(walFileName)
if err != nil {
a.log.Warn("Could not parse WAL filename (continuing anyway)", "file", walFileName, "error", err)
timeline, segment = 0, 0 // Use defaults for non-standard names
}
// Determine target archive path
archivePath := filepath.Join(config.ArchiveDir, walFileName)
if config.CompressWAL {
archivePath += ".gz"
}
if config.EncryptWAL {
archivePath += ".enc"
}
// Copy WAL file to archive
srcFile, err := os.Open(walFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open WAL file %s: %w", walFilePath, err)
}
defer srcFile.Close()
dstFile, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return nil, fmt.Errorf("failed to create archive file %s: %w", archivePath, err)
}
defer dstFile.Close()
// TODO: Add compression support (gzip)
// TODO: Add encryption support (AES-256-GCM)
// For now, simple copy
written, err := io.Copy(dstFile, srcFile)
if err != nil {
return nil, fmt.Errorf("failed to copy WAL file to archive: %w", err)
}
if written != stat.Size() {
return nil, fmt.Errorf("incomplete WAL copy: wrote %d bytes, expected %d", written, stat.Size())
}
// Sync to disk to ensure durability
if err := dstFile.Sync(); err != nil {
return nil, fmt.Errorf("failed to sync WAL archive to disk: %w", err)
}
// Verify archive was created successfully
archiveStat, err := os.Stat(archivePath)
if err != nil {
return nil, fmt.Errorf("failed to verify archived WAL file: %w", err)
}
info := &WALArchiveInfo{
WALFileName: walFileName,
ArchivePath: archivePath,
OriginalSize: stat.Size(),
ArchivedSize: archiveStat.Size(),
Timeline: timeline,
Segment: segment,
ArchivedAt: time.Now(),
Compressed: config.CompressWAL,
Encrypted: config.EncryptWAL,
}
a.log.Info("WAL file archived successfully",
"wal", walFileName,
"archive", archivePath,
"size", stat.Size(),
"timeline", timeline,
"segment", segment)
return info, nil
}
// ParseWALFileName extracts timeline and segment number from WAL filename
// WAL filename format: 000000010000000000000001
// - First 8 hex digits: timeline ID
// - Next 8 hex digits: log file ID
// - Last 8 hex digits: segment number
func ParseWALFileName(filename string) (timeline uint32, segment uint64, err error) {
// Remove any extensions (.gz, .enc, etc.)
base := filepath.Base(filename)
base = strings.TrimSuffix(base, ".gz")
base = strings.TrimSuffix(base, ".enc")
// WAL files are 24 hex characters
if len(base) != 24 {
return 0, 0, fmt.Errorf("invalid WAL filename length: expected 24 characters, got %d", len(base))
}
// Parse timeline (first 8 chars)
_, err = fmt.Sscanf(base[0:8], "%08X", &timeline)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse timeline from WAL filename: %w", err)
}
// Parse segment (last 16 chars as combined log file + segment)
_, err = fmt.Sscanf(base[8:24], "%016X", &segment)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse segment from WAL filename: %w", err)
}
return timeline, segment, nil
}
// ListArchivedWALFiles returns all WAL files in the archive directory
func (a *Archiver) ListArchivedWALFiles(config ArchiveConfig) ([]WALArchiveInfo, error) {
entries, err := os.ReadDir(config.ArchiveDir)
if err != nil {
if os.IsNotExist(err) {
return []WALArchiveInfo{}, nil // Empty archive is valid
}
return nil, fmt.Errorf("failed to read WAL archive directory: %w", err)
}
var archives []WALArchiveInfo
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
// Skip non-WAL files (must be 24 hex chars possibly with .gz/.enc extensions)
baseName := strings.TrimSuffix(strings.TrimSuffix(filename, ".gz"), ".enc")
if len(baseName) != 24 {
continue
}
timeline, segment, err := ParseWALFileName(filename)
if err != nil {
a.log.Warn("Skipping invalid WAL file", "file", filename, "error", err)
continue
}
info, err := entry.Info()
if err != nil {
a.log.Warn("Could not stat WAL file", "file", filename, "error", err)
continue
}
archives = append(archives, WALArchiveInfo{
WALFileName: baseName,
ArchivePath: filepath.Join(config.ArchiveDir, filename),
ArchivedSize: info.Size(),
Timeline: timeline,
Segment: segment,
ArchivedAt: info.ModTime(),
Compressed: strings.HasSuffix(filename, ".gz"),
Encrypted: strings.HasSuffix(filename, ".enc"),
})
}
return archives, nil
}
// CleanupOldWALFiles removes WAL archives older than retention period
func (a *Archiver) CleanupOldWALFiles(ctx context.Context, config ArchiveConfig) (int, error) {
if config.RetentionDays <= 0 {
return 0, nil // No cleanup if retention not set
}
cutoffTime := time.Now().AddDate(0, 0, -config.RetentionDays)
a.log.Info("Cleaning up WAL archives", "older_than", cutoffTime.Format("2006-01-02"), "retention_days", config.RetentionDays)
archives, err := a.ListArchivedWALFiles(config)
if err != nil {
return 0, fmt.Errorf("failed to list WAL archives: %w", err)
}
deleted := 0
for _, archive := range archives {
if archive.ArchivedAt.Before(cutoffTime) {
a.log.Debug("Removing old WAL archive", "file", archive.WALFileName, "archived_at", archive.ArchivedAt)
if err := os.Remove(archive.ArchivePath); err != nil {
a.log.Warn("Failed to remove old WAL archive", "file", archive.ArchivePath, "error", err)
continue
}
deleted++
}
}
a.log.Info("WAL cleanup completed", "deleted", deleted, "total_archives", len(archives))
return deleted, nil
}
// GetArchiveStats returns statistics about WAL archives
func (a *Archiver) GetArchiveStats(config ArchiveConfig) (*ArchiveStats, error) {
archives, err := a.ListArchivedWALFiles(config)
if err != nil {
return nil, err
}
stats := &ArchiveStats{
TotalFiles: len(archives),
CompressedFiles: 0,
EncryptedFiles: 0,
TotalSize: 0,
}
if len(archives) > 0 {
stats.OldestArchive = archives[0].ArchivedAt
stats.NewestArchive = archives[0].ArchivedAt
}
for _, archive := range archives {
stats.TotalSize += archive.ArchivedSize
if archive.Compressed {
stats.CompressedFiles++
}
if archive.Encrypted {
stats.EncryptedFiles++
}
if archive.ArchivedAt.Before(stats.OldestArchive) {
stats.OldestArchive = archive.ArchivedAt
}
if archive.ArchivedAt.After(stats.NewestArchive) {
stats.NewestArchive = archive.ArchivedAt
}
}
return stats, nil
}
// ArchiveStats contains statistics about WAL archives
type ArchiveStats struct {
TotalFiles int `json:"total_files"`
CompressedFiles int `json:"compressed_files"`
EncryptedFiles int `json:"encrypted_files"`
TotalSize int64 `json:"total_size"`
OldestArchive time.Time `json:"oldest_archive"`
NewestArchive time.Time `json:"newest_archive"`
}
// FormatSize returns human-readable size
func (s *ArchiveStats) FormatSize() string {
const (
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
)
size := float64(s.TotalSize)
switch {
case size >= GB:
return fmt.Sprintf("%.2f GB", size/GB)
case size >= MB:
return fmt.Sprintf("%.2f MB", size/MB)
case size >= KB:
return fmt.Sprintf("%.2f KB", size/KB)
default:
return fmt.Sprintf("%d B", s.TotalSize)
}
}

386
internal/wal/pitr_config.go Normal file
View File

@@ -0,0 +1,386 @@
package wal
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"dbbackup/internal/config"
"dbbackup/internal/logger"
)
// PITRManager manages Point-in-Time Recovery configuration
type PITRManager struct {
cfg *config.Config
log logger.Logger
}
// PITRConfig holds PITR settings
type PITRConfig struct {
Enabled bool
ArchiveMode string // "on", "off", "always"
ArchiveCommand string
ArchiveDir string
WALLevel string // "minimal", "replica", "logical"
MaxWALSenders int
WALKeepSize string // e.g., "1GB"
RestoreCommand string
}
// RecoveryTarget specifies the point-in-time to recover to
type RecoveryTarget struct {
TargetTime *time.Time // Recover to specific timestamp
TargetXID string // Recover to transaction ID
TargetName string // Recover to named restore point
TargetLSN string // Recover to Log Sequence Number
TargetImmediate bool // Recover as soon as consistent state is reached
TargetInclusive bool // Include target transaction
RecoveryEndAction string // "pause", "promote", "shutdown"
}
// NewPITRManager creates a new PITR manager
func NewPITRManager(cfg *config.Config, log logger.Logger) *PITRManager {
return &PITRManager{
cfg: cfg,
log: log,
}
}
// EnablePITR configures PostgreSQL for PITR by modifying postgresql.conf
func (pm *PITRManager) EnablePITR(ctx context.Context, archiveDir string) error {
pm.log.Info("Enabling PITR (Point-in-Time Recovery)", "archive_dir", archiveDir)
// Ensure archive directory exists
if err := os.MkdirAll(archiveDir, 0700); err != nil {
return fmt.Errorf("failed to create WAL archive directory: %w", err)
}
// Find postgresql.conf location
confPath, err := pm.findPostgreSQLConf(ctx)
if err != nil {
return fmt.Errorf("failed to locate postgresql.conf: %w", err)
}
pm.log.Info("Found PostgreSQL configuration", "path", confPath)
// Backup original configuration
backupPath := confPath + ".backup." + time.Now().Format("20060102_150405")
if err := pm.backupFile(confPath, backupPath); err != nil {
return fmt.Errorf("failed to backup postgresql.conf: %w", err)
}
pm.log.Info("Created configuration backup", "backup", backupPath)
// Get absolute path to dbbackup binary
dbbackupPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get dbbackup executable path: %w", err)
}
// Build archive command that calls dbbackup
archiveCommand := fmt.Sprintf("%s wal archive %%p %%f --archive-dir %s", dbbackupPath, archiveDir)
// Settings to enable PITR
settings := map[string]string{
"wal_level": "replica", // Required for PITR
"archive_mode": "on",
"archive_command": archiveCommand,
"max_wal_senders": "3",
"wal_keep_size": "1GB", // Keep at least 1GB of WAL
}
// Update postgresql.conf
if err := pm.updatePostgreSQLConf(confPath, settings); err != nil {
return fmt.Errorf("failed to update postgresql.conf: %w", err)
}
pm.log.Info("✅ PITR configuration updated successfully")
pm.log.Warn("⚠️ PostgreSQL restart required for changes to take effect")
pm.log.Info("To restart PostgreSQL:")
pm.log.Info(" sudo systemctl restart postgresql")
pm.log.Info(" OR: sudo pg_ctlcluster <version> <cluster> restart")
return nil
}
// DisablePITR disables PITR by setting archive_mode = off
func (pm *PITRManager) DisablePITR(ctx context.Context) error {
pm.log.Info("Disabling PITR")
confPath, err := pm.findPostgreSQLConf(ctx)
if err != nil {
return fmt.Errorf("failed to locate postgresql.conf: %w", err)
}
// Backup configuration
backupPath := confPath + ".backup." + time.Now().Format("20060102_150405")
if err := pm.backupFile(confPath, backupPath); err != nil {
return fmt.Errorf("failed to backup postgresql.conf: %w", err)
}
settings := map[string]string{
"archive_mode": "off",
"archive_command": "", // Clear command
}
if err := pm.updatePostgreSQLConf(confPath, settings); err != nil {
return fmt.Errorf("failed to update postgresql.conf: %w", err)
}
pm.log.Info("✅ PITR disabled successfully")
pm.log.Warn("⚠️ PostgreSQL restart required")
return nil
}
// GetCurrentPITRConfig reads current PITR settings from PostgreSQL
func (pm *PITRManager) GetCurrentPITRConfig(ctx context.Context) (*PITRConfig, error) {
confPath, err := pm.findPostgreSQLConf(ctx)
if err != nil {
return nil, err
}
file, err := os.Open(confPath)
if err != nil {
return nil, fmt.Errorf("failed to open postgresql.conf: %w", err)
}
defer file.Close()
config := &PITRConfig{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip comments and empty lines
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Parse key = value
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.Trim(strings.TrimSpace(parts[1]), "'\"")
switch key {
case "wal_level":
config.WALLevel = value
case "archive_mode":
config.ArchiveMode = value
config.Enabled = (value == "on" || value == "always")
case "archive_command":
config.ArchiveCommand = value
case "max_wal_senders":
fmt.Sscanf(value, "%d", &config.MaxWALSenders)
case "wal_keep_size":
config.WALKeepSize = value
case "restore_command":
config.RestoreCommand = value
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading postgresql.conf: %w", err)
}
return config, nil
}
// CreateRecoveryConf creates recovery configuration for PITR restore
// PostgreSQL 12+: Creates recovery.signal and modifies postgresql.conf
// PostgreSQL <12: Creates recovery.conf
func (pm *PITRManager) CreateRecoveryConf(ctx context.Context, dataDir string, target RecoveryTarget, walArchiveDir string) error {
pm.log.Info("Creating recovery configuration", "data_dir", dataDir)
// Detect PostgreSQL version to determine recovery file format
version, err := pm.getPostgreSQLVersion(ctx)
if err != nil {
pm.log.Warn("Could not detect PostgreSQL version, assuming >= 12", "error", err)
version = 12 // Default to newer format
}
if version >= 12 {
return pm.createRecoverySignal(ctx, dataDir, target, walArchiveDir)
} else {
return pm.createLegacyRecoveryConf(dataDir, target, walArchiveDir)
}
}
// createRecoverySignal creates recovery.signal for PostgreSQL 12+
func (pm *PITRManager) createRecoverySignal(ctx context.Context, dataDir string, target RecoveryTarget, walArchiveDir string) error {
// Create recovery.signal file (empty file that triggers recovery mode)
signalPath := filepath.Join(dataDir, "recovery.signal")
if err := os.WriteFile(signalPath, []byte{}, 0600); err != nil {
return fmt.Errorf("failed to create recovery.signal: %w", err)
}
pm.log.Info("Created recovery.signal", "path", signalPath)
// Recovery settings go in postgresql.auto.conf (PostgreSQL 12+)
autoConfPath := filepath.Join(dataDir, "postgresql.auto.conf")
// Build recovery settings
var settings []string
settings = append(settings, fmt.Sprintf("restore_command = 'cp %s/%%f %%p'", walArchiveDir))
if target.TargetTime != nil {
settings = append(settings, fmt.Sprintf("recovery_target_time = '%s'", target.TargetTime.Format("2006-01-02 15:04:05")))
} else if target.TargetXID != "" {
settings = append(settings, fmt.Sprintf("recovery_target_xid = '%s'", target.TargetXID))
} else if target.TargetName != "" {
settings = append(settings, fmt.Sprintf("recovery_target_name = '%s'", target.TargetName))
} else if target.TargetLSN != "" {
settings = append(settings, fmt.Sprintf("recovery_target_lsn = '%s'", target.TargetLSN))
} else if target.TargetImmediate {
settings = append(settings, "recovery_target = 'immediate'")
}
if target.RecoveryEndAction != "" {
settings = append(settings, fmt.Sprintf("recovery_target_action = '%s'", target.RecoveryEndAction))
}
// Append to postgresql.auto.conf
f, err := os.OpenFile(autoConfPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return fmt.Errorf("failed to open postgresql.auto.conf: %w", err)
}
defer f.Close()
if _, err := f.WriteString("\n# PITR Recovery Configuration (added by dbbackup)\n"); err != nil {
return err
}
for _, setting := range settings {
if _, err := f.WriteString(setting + "\n"); err != nil {
return err
}
}
pm.log.Info("Recovery configuration added to postgresql.auto.conf", "path", autoConfPath)
return nil
}
// createLegacyRecoveryConf creates recovery.conf for PostgreSQL < 12
func (pm *PITRManager) createLegacyRecoveryConf(dataDir string, target RecoveryTarget, walArchiveDir string) error {
recoveryConfPath := filepath.Join(dataDir, "recovery.conf")
var content strings.Builder
content.WriteString("# Recovery Configuration (created by dbbackup)\n")
content.WriteString(fmt.Sprintf("restore_command = 'cp %s/%%f %%p'\n", walArchiveDir))
if target.TargetTime != nil {
content.WriteString(fmt.Sprintf("recovery_target_time = '%s'\n", target.TargetTime.Format("2006-01-02 15:04:05")))
}
// Add other target types...
if err := os.WriteFile(recoveryConfPath, []byte(content.String()), 0600); err != nil {
return fmt.Errorf("failed to create recovery.conf: %w", err)
}
pm.log.Info("Created recovery.conf", "path", recoveryConfPath)
return nil
}
// Helper functions
func (pm *PITRManager) findPostgreSQLConf(ctx context.Context) (string, error) {
// Try common locations
commonPaths := []string{
"/var/lib/postgresql/data/postgresql.conf",
"/etc/postgresql/*/main/postgresql.conf",
"/usr/local/pgsql/data/postgresql.conf",
}
for _, pattern := range commonPaths {
matches, _ := filepath.Glob(pattern)
for _, path := range matches {
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
}
// Try to get from PostgreSQL directly
cmd := exec.CommandContext(ctx, "psql", "-U", pm.cfg.User, "-t", "-c", "SHOW config_file")
output, err := cmd.Output()
if err == nil {
path := strings.TrimSpace(string(output))
if _, err := os.Stat(path); err == nil {
return path, nil
}
}
return "", fmt.Errorf("could not locate postgresql.conf. Please specify --pg-conf-path")
}
func (pm *PITRManager) backupFile(src, dst string) error {
input, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, input, 0644)
}
func (pm *PITRManager) updatePostgreSQLConf(confPath string, settings map[string]string) error {
file, err := os.Open(confPath)
if err != nil {
return err
}
defer file.Close()
var lines []string
existingKeys := make(map[string]bool)
scanner := bufio.NewScanner(file)
// Read existing configuration and track which keys are already present
for scanner.Scan() {
line := scanner.Text()
lines = append(lines, line)
// Check if this line sets one of our keys
for key := range settings {
if matched, _ := regexp.MatchString(fmt.Sprintf(`^\s*%s\s*=`, key), line); matched {
existingKeys[key] = true
}
}
}
if err := scanner.Err(); err != nil {
return err
}
// Append missing settings
for key, value := range settings {
if !existingKeys[key] {
if value == "" {
lines = append(lines, fmt.Sprintf("# %s = '' # Disabled by dbbackup", key))
} else {
lines = append(lines, fmt.Sprintf("%s = '%s' # Added by dbbackup", key, value))
}
}
}
// Write updated configuration
output := strings.Join(lines, "\n") + "\n"
return os.WriteFile(confPath, []byte(output), 0644)
}
func (pm *PITRManager) getPostgreSQLVersion(ctx context.Context) (int, error) {
cmd := exec.CommandContext(ctx, "psql", "-U", pm.cfg.User, "-t", "-c", "SHOW server_version")
output, err := cmd.Output()
if err != nil {
return 0, err
}
versionStr := strings.TrimSpace(string(output))
var major int
fmt.Sscanf(versionStr, "%d", &major)
return major, nil
}