From cf6e8f212afb355d3ddd7de68c567f84d5fc2a22 Mon Sep 17 00:00:00 2001 From: Rostislav Dugin Date: Fri, 20 Mar 2026 17:04:46 +0300 Subject: [PATCH] FIX (agent): Adjust restore path for Docker PG restoration --- agent/cmd/main.go | 2 +- agent/internal/features/restore/restorer.go | 57 ++++++-- .../features/restore/restorer_test.go | 124 ++++++++++++++++-- .../backups/ui/AgentRestoreComponent.tsx | 2 + 4 files changed, 157 insertions(+), 28 deletions(-) diff --git a/agent/cmd/main.go b/agent/cmd/main.go index 0bb42bb..d79b6eb 100644 --- a/agent/cmd/main.go +++ b/agent/cmd/main.go @@ -145,7 +145,7 @@ func runRestore(args []string) { } apiClient := api.NewClient(cfg.DatabasusHost, cfg.Token, log) - restorer := restore.NewRestorer(apiClient, log, *pgDataDir, *backupID, *targetTime) + restorer := restore.NewRestorer(apiClient, log, *pgDataDir, *backupID, *targetTime, cfg.PgType) ctx := context.Background() if err := restorer.Run(ctx); err != nil { diff --git a/agent/internal/features/restore/restorer.go b/agent/internal/features/restore/restorer.go index 5200ffa..a23209e 100644 --- a/agent/internal/features/restore/restorer.go +++ b/agent/internal/features/restore/restorer.go @@ -18,11 +18,12 @@ import ( ) const ( - walRestoreDir = "databasus-wal-restore" - maxRetryAttempts = 3 - retryBaseDelay = 1 * time.Second - recoverySignalFile = "recovery.signal" - autoConfFile = "postgresql.auto.conf" + walRestoreDir = "databasus-wal-restore" + maxRetryAttempts = 3 + retryBaseDelay = 1 * time.Second + recoverySignalFile = "recovery.signal" + autoConfFile = "postgresql.auto.conf" + dockerContainerPgDataDir = "/var/lib/postgresql/data" ) var retryDelayOverride *time.Duration @@ -33,6 +34,7 @@ type Restorer struct { targetPgDataDir string backupID string targetTime string + pgType string } func NewRestorer( @@ -41,6 +43,7 @@ func NewRestorer( targetPgDataDir string, backupID string, targetTime string, + pgType string, ) *Restorer { return &Restorer{ apiClient, @@ -48,6 +51,7 @@ func NewRestorer( targetPgDataDir, backupID, targetTime, + pgType, } } @@ -328,14 +332,11 @@ func (r *Restorer) configurePostgresRecovery(parsedTargetTime *time.Time) error return fmt.Errorf("create recovery.signal: %w", err) } - absPgDataDir, err := filepath.Abs(r.targetPgDataDir) + walRestoreAbsPath, err := r.resolveWalRestorePath() if err != nil { - return fmt.Errorf("resolve absolute path: %w", err) + return err } - absPgDataDir = filepath.ToSlash(absPgDataDir) - walRestoreAbsPath := absPgDataDir + "/" + walRestoreDir - autoConfPath := filepath.Join(r.targetPgDataDir, autoConfFile) autoConfFile, err := os.OpenFile(autoConfPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) @@ -363,10 +364,11 @@ func (r *Restorer) configurePostgresRecovery(parsedTargetTime *time.Time) error func (r *Restorer) printCompletionMessage() { absPgDataDir, _ := filepath.Abs(r.targetPgDataDir) + isDocker := r.pgType == "docker" - fmt.Printf(` -Restore complete. PGDATA directory is ready at %s. + fmt.Printf("\nRestore complete. PGDATA directory is ready at %s.\n", absPgDataDir) + fmt.Print(` What happens when you start PostgreSQL: 1. PostgreSQL detects recovery.signal and enters recovery mode 2. It replays WAL from the basebackup's consistency point @@ -375,14 +377,43 @@ What happens when you start PostgreSQL: 5. recovery_end_command automatically removes databasus-wal-restore/ 6. PostgreSQL promotes to primary and removes recovery.signal 7. Normal operations resume +`) + if isDocker { + fmt.Printf(` +Start PostgreSQL by launching a container with the restored data mounted: + docker run -d -v %s:%s postgres: + +Or if you have an existing container: + docker start + +Ensure %s is mounted as the container's pgdata volume at %s. +`, absPgDataDir, dockerContainerPgDataDir, absPgDataDir, dockerContainerPgDataDir) + } else { + fmt.Printf(` Start PostgreSQL: pg_ctl -D %s start Note: If you move the PGDATA directory before starting PostgreSQL, update restore_command and recovery_end_command paths in postgresql.auto.conf accordingly. -`, absPgDataDir, absPgDataDir) +`, absPgDataDir) + } +} + +func (r *Restorer) resolveWalRestorePath() (string, error) { + if r.pgType == "docker" { + return dockerContainerPgDataDir + "/" + walRestoreDir, nil + } + + absPgDataDir, err := filepath.Abs(r.targetPgDataDir) + if err != nil { + return "", fmt.Errorf("resolve absolute path: %w", err) + } + + absPgDataDir = filepath.ToSlash(absPgDataDir) + + return absPgDataDir + "/" + walRestoreDir, nil } func (r *Restorer) getRetryDelay(attempt int) time.Duration { diff --git a/agent/internal/features/restore/restorer_test.go b/agent/internal/features/restore/restorer_test.go index b110e7f..8883edd 100644 --- a/agent/internal/features/restore/restorer_test.go +++ b/agent/internal/features/restore/restorer_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -83,7 +84,7 @@ func Test_RunRestore_WhenBasebackupAndWalSegmentsAvailable_FilesExtractedAndReco }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.NoError(t, err) @@ -149,7 +150,7 @@ func Test_RunRestore_WhenTargetTimeProvided_RecoveryTargetTimeWrittenToConfig(t }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "2026-02-28T14:30:00Z") + restorer := newTestRestorer(server.URL, targetDir, "", "2026-02-28T14:30:00Z", "") err := restorer.Run(context.Background()) require.NoError(t, err) @@ -166,7 +167,7 @@ func Test_RunRestore_WhenPgDataDirNotEmpty_ReturnsError(t *testing.T) { err := os.WriteFile(filepath.Join(targetDir, "existing-file"), []byte("data"), 0o644) require.NoError(t, err) - restorer := newTestRestorer("http://localhost:0", targetDir, "", "") + restorer := newTestRestorer("http://localhost:0", targetDir, "", "", "") err = restorer.Run(context.Background()) require.Error(t, err) @@ -176,7 +177,7 @@ func Test_RunRestore_WhenPgDataDirNotEmpty_ReturnsError(t *testing.T) { func Test_RunRestore_WhenPgDataDirDoesNotExist_ReturnsError(t *testing.T) { nonExistentDir := filepath.Join(os.TempDir(), "databasus-test-nonexistent-dir-12345") - restorer := newTestRestorer("http://localhost:0", nonExistentDir, "", "") + restorer := newTestRestorer("http://localhost:0", nonExistentDir, "", "", "") err := restorer.Run(context.Background()) require.Error(t, err) @@ -194,7 +195,7 @@ func Test_RunRestore_WhenNoBackupsAvailable_ReturnsError(t *testing.T) { }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.Error(t, err) @@ -213,7 +214,7 @@ func Test_RunRestore_WhenWalChainBroken_ReturnsError(t *testing.T) { }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.Error(t, err) @@ -274,7 +275,7 @@ func Test_DownloadWalSegment_WhenFirstAttemptFails_RetriesAndSucceeds(t *testing }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") origDelay := retryDelayOverride testDelay := 10 * time.Millisecond @@ -333,7 +334,7 @@ func Test_DownloadWalSegment_WhenAllAttemptsFail_ReturnsErrorWithSegmentName(t * }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") origDelay := retryDelayOverride testDelay := 10 * time.Millisecond @@ -348,7 +349,7 @@ func Test_DownloadWalSegment_WhenAllAttemptsFail_ReturnsErrorWithSegmentName(t * func Test_RunRestore_WhenInvalidTargetTimeFormat_ReturnsError(t *testing.T) { targetDir := createTestTargetDir(t) - restorer := newTestRestorer("http://localhost:0", targetDir, "", "not-a-valid-time") + restorer := newTestRestorer("http://localhost:0", targetDir, "", "not-a-valid-time", "") err := restorer.Run(context.Background()) require.Error(t, err) @@ -381,7 +382,7 @@ func Test_RunRestore_WhenBasebackupDownloadFails_ReturnsError(t *testing.T) { }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.Error(t, err) @@ -420,7 +421,7 @@ func Test_RunRestore_WhenNoWalSegmentsInPlan_BasebackupRestoredSuccessfully(t *t }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.NoError(t, err) @@ -483,7 +484,7 @@ func Test_RunRestore_WhenMakingApiCalls_AuthTokenIncludedInRequests(t *testing.T }) targetDir := createTestTargetDir(t) - restorer := newTestRestorer(server.URL, targetDir, "", "") + restorer := newTestRestorer(server.URL, targetDir, "", "", "") err := restorer.Run(context.Background()) require.NoError(t, err) @@ -498,6 +499,101 @@ func Test_RunRestore_WhenMakingApiCalls_AuthTokenIncludedInRequests(t *testing.T } } +func Test_ConfigurePostgresRecovery_WhenPgTypeHost_UsesHostAbsolutePath(t *testing.T) { + tarFiles := map[string][]byte{"PG_VERSION": []byte("16")} + zstdTarData := createZstdTar(t, tarFiles) + + server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testRestorePlanPath: + writeJSON(w, api.GetRestorePlanResponse{ + FullBackup: api.RestorePlanFullBackup{ + BackupID: testFullBackupID, + PgVersion: "16", + CreatedAt: time.Now().UTC(), + SizeBytes: 1024, + }, + WalSegments: []api.RestorePlanWalSegment{}, + TotalSizeBytes: 1024, + LatestAvailableSegment: "", + }) + + case testRestoreDownloadPath: + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zstdTarData) + + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + targetDir := createTestTargetDir(t) + restorer := newTestRestorer(server.URL, targetDir, "", "", "host") + + err := restorer.Run(context.Background()) + require.NoError(t, err) + + autoConfContent, err := os.ReadFile(filepath.Join(targetDir, "postgresql.auto.conf")) + require.NoError(t, err) + autoConfStr := string(autoConfContent) + + absTargetDir, _ := filepath.Abs(targetDir) + absTargetDir = filepath.ToSlash(absTargetDir) + expectedWalPath := absTargetDir + "/" + walRestoreDir + + assert.Contains(t, autoConfStr, fmt.Sprintf("restore_command = 'cp %s/%%f %%p'", expectedWalPath)) + assert.Contains(t, autoConfStr, fmt.Sprintf("recovery_end_command = 'rm -rf %s'", expectedWalPath)) + assert.NotContains(t, autoConfStr, "/var/lib/postgresql/data") +} + +func Test_ConfigurePostgresRecovery_WhenPgTypeDocker_UsesContainerPath(t *testing.T) { + tarFiles := map[string][]byte{"PG_VERSION": []byte("16")} + zstdTarData := createZstdTar(t, tarFiles) + + server := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case testRestorePlanPath: + writeJSON(w, api.GetRestorePlanResponse{ + FullBackup: api.RestorePlanFullBackup{ + BackupID: testFullBackupID, + PgVersion: "16", + CreatedAt: time.Now().UTC(), + SizeBytes: 1024, + }, + WalSegments: []api.RestorePlanWalSegment{}, + TotalSizeBytes: 1024, + LatestAvailableSegment: "", + }) + + case testRestoreDownloadPath: + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zstdTarData) + + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + targetDir := createTestTargetDir(t) + restorer := newTestRestorer(server.URL, targetDir, "", "", "docker") + + err := restorer.Run(context.Background()) + require.NoError(t, err) + + autoConfContent, err := os.ReadFile(filepath.Join(targetDir, "postgresql.auto.conf")) + require.NoError(t, err) + autoConfStr := string(autoConfContent) + + expectedWalPath := "/var/lib/postgresql/data/" + walRestoreDir + + assert.Contains(t, autoConfStr, fmt.Sprintf("restore_command = 'cp %s/%%f %%p'", expectedWalPath)) + assert.Contains(t, autoConfStr, fmt.Sprintf("recovery_end_command = 'rm -rf %s'", expectedWalPath)) + + absTargetDir, _ := filepath.Abs(targetDir) + absTargetDir = filepath.ToSlash(absTargetDir) + assert.NotContains(t, autoConfStr, absTargetDir) +} + func newTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { t.Helper() @@ -601,10 +697,10 @@ func createZstdData(t *testing.T, data []byte) []byte { return buffer.Bytes() } -func newTestRestorer(serverURL, targetPgDataDir, backupID, targetTime string) *Restorer { +func newTestRestorer(serverURL, targetPgDataDir, backupID, targetTime, pgType string) *Restorer { apiClient := api.NewClient(serverURL, "test-token", logger.GetLogger()) - return NewRestorer(apiClient, logger.GetLogger(), targetPgDataDir, backupID, targetTime) + return NewRestorer(apiClient, logger.GetLogger(), targetPgDataDir, backupID, targetTime, pgType) } func writeJSON(w http.ResponseWriter, value any) { diff --git a/frontend/src/features/backups/ui/AgentRestoreComponent.tsx b/frontend/src/features/backups/ui/AgentRestoreComponent.tsx index d72557d..828d82f 100644 --- a/frontend/src/features/backups/ui/AgentRestoreComponent.tsx +++ b/frontend/src/features/backups/ui/AgentRestoreComponent.tsx @@ -75,6 +75,7 @@ export const AgentRestoreComponent = ({ database, backup }: Props) => { ` --db-id=${database.id} \\`, ` --token= \\`, ` --backup-id=${backup.id} \\`, + ...(isDocker ? [' --pg-type=docker \\'] : []), ` --target-dir=${targetDirPlaceholder}`, ].join('\n'); @@ -84,6 +85,7 @@ export const AgentRestoreComponent = ({ database, backup }: Props) => { ` --db-id=${database.id} \\`, ` --token= \\`, ` --backup-id=${backup.id} \\`, + ...(isDocker ? [' --pg-type=docker \\'] : []), ` --target-dir=${targetDirPlaceholder} \\`, ` --target-time=`, ].join('\n');