FIX (agent): Adjust restore path for Docker PG restoration

This commit is contained in:
Rostislav Dugin
2026-03-20 17:04:46 +03:00
parent 6ee7e02f5d
commit cf6e8f212a
4 changed files with 157 additions and 28 deletions

View File

@@ -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 {

View File

@@ -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:<VERSION>
Or if you have an existing container:
docker start <CONTAINER_NAME>
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 {

View File

@@ -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) {

View File

@@ -75,6 +75,7 @@ export const AgentRestoreComponent = ({ database, backup }: Props) => {
` --db-id=${database.id} \\`,
` --token=<YOUR_AGENT_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=<YOUR_AGENT_TOKEN> \\`,
` --backup-id=${backup.id} \\`,
...(isDocker ? [' --pg-type=docker \\'] : []),
` --target-dir=${targetDirPlaceholder} \\`,
` --target-time=<RFC3339_TIMESTAMP>`,
].join('\n');