Files
wehub-resource-sync 75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:11:07 +08:00

1111 lines
41 KiB
Go

package physicaltesting
import (
"cmp"
"context"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
backuping_physical "databasus-backend/internal/features/backups/backups/backuping/physical"
backups_controllers_physical "databasus-backend/internal/features/backups/backups/controllers/physical"
backups_core_enums "databasus-backend/internal/features/backups/backups/core/enums"
"databasus-backend/internal/features/backups/backups/core/physical/chain_view"
physical_enums "databasus-backend/internal/features/backups/backups/core/physical/enums"
physical_repositories "databasus-backend/internal/features/backups/backups/core/physical/repositories"
backups_dto_physical "databasus-backend/internal/features/backups/backups/dto/physical"
postgresql_executor "databasus-backend/internal/features/backups/backups/usecases/physical/postgresql"
backups_config_physical "databasus-backend/internal/features/backups/config/physical"
"databasus-backend/internal/features/databases"
postgresql_physical "databasus-backend/internal/features/databases/databases/postgresql/physical"
"databasus-backend/internal/features/intervals"
"databasus-backend/internal/features/notifiers"
"databasus-backend/internal/features/storages"
users_middleware "databasus-backend/internal/features/users/middleware"
users_services "databasus-backend/internal/features/users/services"
workspaces_controllers "databasus-backend/internal/features/workspaces/controllers"
workspaces_testing "databasus-backend/internal/features/workspaces/testing"
"databasus-backend/internal/util/encryption"
"databasus-backend/internal/util/logger"
test_utils "databasus-backend/internal/util/testing"
"databasus-backend/internal/util/testing/containers"
"databasus-backend/internal/util/walmath"
)
const (
restoreWorkDir = "/restore"
restoredPgUser = "testuser"
restoredPgPassword = "testpassword"
restoredPgDatabase = "testdb"
)
// setupReplicationOnlyFixture boots a throwaway replication-capable source for one PostgreSQL major,
// wires a scheduler-driven physical fixture against it, and switches its backup credentials to a
// replication-only user over the API — the common preamble every physical E2E shares. The returned
// router is the one all subsequent API calls go through.
func setupReplicationOnlyFixture(
t *testing.T,
version string,
image string,
backupType postgresql_physical.BackupType,
sourceOptions ...containers.PhysicalPostgresOption,
) (*gin.Engine, *postgresql_executor.PhysicalDBFixture) {
t.Helper()
source := containers.StartPhysicalPostgres(t, image, sourceOptions...)
router := newPhysicalTestRouter()
fixture := postgresql_executor.SetupPhysicalDBForScheduledBackupVersion(
t, source.Host, source.Port, version, backupType)
useReplicationOnlyUserViaAPI(t, router, fixture)
return router, fixture
}
// prepareRestoreTarget boots a throwaway idle restore-target container whose major matches the
// source backup's (a PG 18 PGDATA cannot start under PG 17). It is terminated with its tmpfs /restore
// when the test ends, so no pre/post wipe is needed.
func prepareRestoreTarget(t *testing.T, image string) containers.RestoreTarget {
t.Helper()
return containers.StartPhysicalRestoreTarget(t, image)
}
// restoredClusterDir is where the recovery script builds PGDATA under restoreWorkDir, mirroring the
// image's layout: PG 18 nests <major>/docker, PG <=17 keeps <out>/data.
func restoredClusterDir(image string) string {
if containers.PostgresMajorVersion(image) >= 18 {
return restoreWorkDir + "/" + strconv.Itoa(containers.PostgresMajorVersion(image)) + "/docker"
}
return restoreWorkDir + "/data"
}
// hostVolumeDirForMount maps the recovery script's output dir to the directory that must be bind-
// mounted at the image's data VOLUME: PG 18's volume root is the output dir itself (the cluster
// nests at <out>/<major>/docker), while PG <=17's volume is PGDATA, i.e. <out>/data.
func hostVolumeDirForMount(image, outDir string) string {
if containers.PostgresMajorVersion(image) >= 18 {
return outDir
}
return filepath.Join(outDir, "data")
}
// hostClusterDir is the on-host PGDATA the recovery script builds under outDir, mirroring
// restoredClusterDir but rooted at a real host path so a test can read the written config files.
func hostClusterDir(image, outDir string) string {
if containers.PostgresMajorVersion(image) >= 18 {
return filepath.Join(outDir, strconv.Itoa(containers.PostgresMajorVersion(image)), "docker")
}
return filepath.Join(outDir, "data")
}
// runRecoveryScriptOnHost runs the server-shipped recovery script on the host in --combine-image mode
// (pg_combinebackup runs in a throwaway postgres container; zstd/tar/docker live on the host), exactly
// as a user pipes curl | sh. It returns the script's combined output and its error so callers can
// assert either success or a refusal.
func runRecoveryScriptOnHost(
t *testing.T,
scriptPath, bundlePath, outDir, image string,
targetTime *time.Time,
) ([]byte, error) {
t.Helper()
args := []string{scriptPath, "--combine-image", image}
if targetTime != nil {
args = append(args, "--target-time", targetTime.UTC().Format("2006-01-02 15:04:05-07:00"))
}
args = append(args, bundlePath, outDir)
out, err := exec.CommandContext(t.Context(), "sh", args...).CombinedOutput()
return out, err
}
// reconstructOnHost runs the recovery script on the host (--combine-image mode) into a fresh output
// dir and returns it. Separate from the boot step so callers can probe the re-restore guard while the
// output is still test-owned (the entrypoint chowns PGDATA to the postgres uid once it boots).
func reconstructOnHost(
t *testing.T,
router *gin.Engine,
image, bundle string,
targetTime *time.Time,
) string {
t.Helper()
script := fetchRecoveryScript(t, router)
outDir := t.TempDir()
out, err := runRecoveryScriptOnHost(t, script, bundle, outDir, image, targetTime)
require.NoError(t, err, "recovery script must succeed into an empty dir:\n%s", out)
return outDir
}
// bootBoundCluster serves a reconstructed output dir through the postgres image's own entrypoint with
// the output bind-mounted at the image VOLUME - the docker-compose path the in-container pg_ctl tests
// bypass.
func bootBoundCluster(t *testing.T, image, outDir string) containers.Endpoint {
t.Helper()
return containers.StartPostgresWithBoundDataDir(t, image, hostVolumeDirForMount(image, outDir))
}
// requireRecoveryRefusesReRestore asserts the guard refuses a second restore into an output dir whose
// cluster path the postgres entrypoint already initialized - the empty-DB trap that motivated it.
func requireRecoveryRefusesReRestore(t *testing.T, router *gin.Engine, image, bundle, outDir string) {
t.Helper()
script := fetchRecoveryScript(t, router)
out, err := runRecoveryScriptOnHost(t, script, bundle, outDir, image, nil)
require.Error(t, err, "re-restoring into an initialized cluster dir must fail; output:\n%s", out)
require.Contains(t, string(out), "already holds a PostgreSQL cluster",
"the guard must name the populated-cluster refusal; output:\n%s", out)
}
// requireRecoveryRefusesMisaimedDir asserts the pre-flight guard refuses an output dir that already
// looks like a live PostgreSQL data/install directory (the user aimed the restore at the wrong place).
// It needs no bundle: the check runs before any download.
func requireRecoveryRefusesMisaimedDir(t *testing.T, router *gin.Engine, image string) {
t.Helper()
outDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(outDir, "pg_hba.conf"), []byte("x\n"), 0o600))
script := fetchRecoveryScript(t, router)
missingBundle := filepath.Join(t.TempDir(), "missing.tar")
out, err := runRecoveryScriptOnHost(t, script, missingBundle, outDir, image, nil)
require.Error(t, err, "restoring into a live data/install dir must fail; output:\n%s", out)
require.Contains(t, string(out), "looks like a PostgreSQL data or install directory",
"the guard must explain the misaimed-directory refusal; output:\n%s", out)
}
// newPhysicalTestRouter wires the physical controller's public (restore-stream)
// and protected routes plus the supporting controllers, mirroring production.
func newPhysicalTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
v1 := router.Group("/api/v1")
backups_controllers_physical.GetPhysicalBackupController().RegisterPublicRoutes(v1)
protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
if routerGroup, ok := protected.(*gin.RouterGroup); ok {
workspaces_controllers.GetWorkspaceController().RegisterRoutes(routerGroup)
workspaces_controllers.GetMembershipController().RegisterRoutes(routerGroup)
databases.GetDatabaseController().RegisterRoutes(routerGroup)
backups_config_physical.GetBackupConfigController().RegisterRoutes(routerGroup)
backups_controllers_physical.GetPhysicalBackupController().RegisterRoutes(routerGroup)
}
storages.SetupDependencies()
databases.SetupDependencies()
notifiers.SetupDependencies()
backups_config_physical.SetupDependencies()
backuping_physical.SetupDependencies()
return router
}
// cronInterval is a once-a-year cron used for both cadences so the scheduler
// never auto-fires on its own clock: the only FULL is the bootstrap one (no prior
// full ⇒ due immediately), and incrementals come solely from the trigger
// endpoint. Cron also satisfies the "incremental strictly more frequent than
// full" config rule trivially.
func cronInterval() intervals.Interval {
expr := "0 0 1 1 *"
return intervals.Interval{Type: intervals.IntervalCron, CronExpression: &expr}
}
// enablePhysicalBackupsViaAPI turns on backups for the fixture's database through
// the config endpoint. isWalStream must match the DB's BackupType: the WAL-stream
// config requires a positive lag threshold, the plain incremental config requires
// zero.
func enablePhysicalBackupsViaAPI(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
isWalStream bool,
) {
t.Helper()
cfg := backups_config_physical.PhysicalBackupConfig{
DatabaseID: fixture.DB.ID,
IsBackupsEnabled: true,
FullBackupInterval: cronInterval(),
IncrementalBackupInterval: cronInterval(),
Retention: backups_config_physical.RetentionChains,
ChainsRetention: backups_config_physical.ChainsRetention{Count: 50},
Encryption: backups_core_enums.BackupEncryptionNone,
StorageID: &fixture.Storage.ID,
Storage: fixture.Storage,
}
if isWalStream {
cfg.WalLagThresholdBytes = 64 * 1024 * 1024
}
test_utils.MakePostRequest(t, router, "/api/v1/backup-configs/physical/save",
"Bearer "+fixture.Owner.Token, cfg, http.StatusOK)
}
func triggerIncrementalViaAPI(t *testing.T, router *gin.Engine, fixture *postgresql_executor.PhysicalDBFixture) {
t.Helper()
test_utils.MakePostRequest(t, router,
"/api/v1/backups/physical/database/"+fixture.DB.ID.String()+"/trigger",
"Bearer "+fixture.Owner.Token,
backups_dto_physical.TriggerBackupRequest{Type: backups_dto_physical.TriggerBackupTypeIncremental},
http.StatusAccepted)
}
// useReplicationOnlyUserViaAPI provisions a fresh LOGIN+REPLICATION role on the
// source through the public API and switches the database's stored backup
// credentials to it, so every subsequent FULL / incremental / WAL operation runs
// through a least-privilege user instead of the superuser the fixture is seeded
// with. The backuper re-fetches the database at execution time, so the switch is
// persisted via the update endpoint — mutating the in-memory fixture alone would
// not reach the running backup.
func useReplicationOnlyUserViaAPI(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
) {
t.Helper()
physical := fixture.DB.PostgresqlPhysical
// The provisioning call connects to the source as the current admin user to run
// CREATE ROLE; the database is identified by ID, the connection block carries the
// superuser the source container was started with.
provisionRequest := databases.Database{
ID: fixture.DB.ID,
WorkspaceID: fixture.DB.WorkspaceID,
Name: fixture.DB.Name,
Type: databases.DatabaseTypePostgresPhysical,
Notifiers: []notifiers.Notifier{*fixture.Notifier},
PostgresqlPhysical: &postgresql_physical.PostgresqlPhysicalDatabase{
Version: physical.Version,
Host: physical.Host,
Port: physical.Port,
Username: restoredPgUser,
Password: restoredPgPassword,
BackupType: physical.BackupType,
},
}
var provisioned databases.CreateReadOnlyUserResponse
test_utils.MakePostRequestAndUnmarshal(t, router,
"/api/v1/databases/create-replication-only-user",
"Bearer "+fixture.Owner.Token, provisionRequest, http.StatusOK, &provisioned)
require.NotEmpty(t, provisioned.Username)
require.NotEmpty(t, provisioned.Password)
switchRequest := provisionRequest
switchRequest.PostgresqlPhysical = &postgresql_physical.PostgresqlPhysicalDatabase{
Version: physical.Version,
Host: physical.Host,
Port: physical.Port,
Username: provisioned.Username,
Password: provisioned.Password,
BackupType: physical.BackupType,
}
test_utils.MakePostRequest(t, router, "/api/v1/databases/update",
"Bearer "+fixture.Owner.Token, switchRequest, http.StatusOK)
// Mirror the switch onto the in-memory fixture so direct source connections in
// the test (slot inspection) use the same identity the backups now run as.
physical.Username = provisioned.Username
physical.Password = provisioned.Password
isMinimal, excessivePrivileges, err := physical.IsUserReplicationOnly(
t.Context(), logger.GetLogger(), encryption.GetFieldEncryptor())
require.NoError(t, err)
require.True(t, isMinimal,
"backups must run through a replication-only user; excessive privileges: %v", excessivePrivileges)
require.Empty(t, excessivePrivileges)
}
// deleteDatabaseViaAPI removes the fixture's database through the public DELETE
// endpoint, which fires the OnBeforeDatabaseRemove listeners (slot + streamer
// cleanup). The fixture's own t.Cleanup re-deletes at test end, but DeleteForTest
// is idempotent (a missing row is a no-op and the listeners tolerate it), so no
// extra guard is needed here.
func deleteDatabaseViaAPI(t *testing.T, router *gin.Engine, fixture *postgresql_executor.PhysicalDBFixture) {
t.Helper()
test_utils.MakeDeleteRequest(t, router,
"/api/v1/databases/"+fixture.DB.ID.String(),
"Bearer "+fixture.Owner.Token, http.StatusNoContent)
}
// waitForSlotPresent polls pg_replication_slots until slotName appears — the WAL
// stream supervisor creates the persistent slot a tick or two after WAL-stream
// backups are enabled — failing the test if it never does.
func waitForSlotPresent(t *testing.T, conn *pgx.Conn, slotName string, timeout time.Duration) {
t.Helper()
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
if postgresql_executor.SlotExists(t, conn, slotName) {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("replication slot %q never appeared within %s", slotName, timeout)
}
// requireDatabaseSlotsGone polls until neither of the database's source-side slots
// remains — the persistent WAL streamer slot (databasus_slot_*) and any transient
// per-backup slot (databasus_basebackup_*) — proving the deleted database left
// nothing pinning WAL. Scoped to this database's own slot names. It polls because a
// bootstrap FULL can briefly hold its per-backup slot and the streamer slot drops
// only once its receiver has fully detached.
func requireDatabaseSlotsGone(
t *testing.T,
conn *pgx.Conn,
fixture *postgresql_executor.PhysicalDBFixture,
timeout time.Duration,
) {
t.Helper()
streamerSlot := fixture.DB.PostgresqlPhysical.ReplicationSlotName
backupSlot := postgresql_executor.SlotName(fixture.DB.PostgresqlPhysical.ID)
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
if !postgresql_executor.SlotExists(t, conn, streamerSlot) &&
!postgresql_executor.SlotExists(t, conn, backupSlot) {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf(
"database %s still has a replication slot pinning WAL %s after deletion "+
"(streamer %q present=%v, per-backup %q present=%v)",
fixture.DB.ID, timeout,
streamerSlot, postgresql_executor.SlotExists(t, conn, streamerSlot),
backupSlot, postgresql_executor.SlotExists(t, conn, backupSlot),
)
}
// listChainBackups returns the database's FULL and incremental rows (the chain
// backups, excluding committed WAL) in chronological order, the oldest — the
// bootstrap FULL — first and the newest at the tip. The flat list endpoint
// interleaves WAL rows newest-first and paginates, so a single page can be all
// WAL and bury the chain; the type filter the API now exposes fetches FULL and
// INCREMENTAL directly, immune to WAL volume. These tests build exactly one
// chain (backups enabled once ⇒ one bootstrap FULL; the once-a-year cron never
// fires a second), so every non-WAL row belongs to that single chain.
func listChainBackups(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
fullRows := listBackupsByType(t, router, fixture, physical_enums.PhysicalBackupTypeFull)
incrRows := listBackupsByType(t, router, fixture, physical_enums.PhysicalBackupTypeIncremental)
chain := slices.Concat(fullRows, incrRows)
slices.SortFunc(chain, func(a, b backups_dto_physical.PhysicalBackupListItem) int {
if byTime := a.CreatedAt.Compare(b.CreatedAt); byTime != 0 {
return byTime
}
return cmp.Compare(a.ID.String(), b.ID.String())
})
return chain
}
// listBackupsByType pulls every backup of one type for the database. The page
// limit is set well above any chain length these tests produce so a single call
// returns them all.
func listBackupsByType(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
backupType physical_enums.PhysicalBackupType,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
var response backups_dto_physical.GetPhysicalBackupsResponse
test_utils.MakeGetRequestAndUnmarshal(t, router,
"/api/v1/backups/physical/database/"+fixture.DB.ID.String()+"/backups?limit=1000&type="+string(backupType),
"Bearer "+fixture.Owner.Token, http.StatusOK, &response)
return response.Backups
}
// waitForChainBackups polls the flat backup list until the database's chain holds
// a COMPLETED FULL plus wantIncrementals COMPLETED incrementals, failing fast if
// any backup reaches ERROR or CHAIN_BROKEN. Returns the matched chain (oldest
// first) so the caller can read LSNs from the tip.
func waitForChainBackups(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
wantIncrementals int,
timeout time.Duration,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
completed := string(physical_enums.PhysicalBackupStatusCompleted)
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
chain := listChainBackups(t, router, fixture)
failFastOnTerminalBackup(t, chain)
fullCompleted := false
completedIncrementals := 0
for _, backup := range chain {
if backup.Status != completed {
continue
}
if backup.Type == physical_enums.PhysicalBackupTypeFull {
fullCompleted = true
} else {
completedIncrementals++
}
}
if fullCompleted && completedIncrementals == wantIncrementals {
return chain
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("chain never reached 1 COMPLETED full + %d COMPLETED incrementals within %s",
wantIncrementals, timeout)
return nil
}
// failFastOnTerminalBackup aborts the wait the moment any chain row is ERROR or
// CHAIN_BROKEN, turning a 3-minute timeout into an immediate, labelled failure.
// A CHAIN_BROKEN incremental still appears as an INCREMENTAL row in the flat
// list, so it is caught here.
func failFastOnTerminalBackup(t *testing.T, chain []backups_dto_physical.PhysicalBackupListItem) {
t.Helper()
for _, backup := range chain {
if backup.Status == string(physical_enums.PhysicalBackupStatusError) ||
backup.Status == string(physical_enums.PhysicalBackupStatusChainBroken) {
t.Fatalf("backup %s (%s) reached terminal failure status %s",
backup.ID, backup.Type, backup.Status)
}
}
}
// rootFullBackupID returns the id of the chain's FULL backup — the key the
// WAL-gap and restore-set resolvers identify a chain by. The FULL's own id is
// the chain's root full id.
func rootFullBackupID(t *testing.T, chain []backups_dto_physical.PhysicalBackupListItem) uuid.UUID {
t.Helper()
for _, backup := range chain {
if backup.Type == physical_enums.PhysicalBackupTypeFull {
return backup.ID
}
}
t.Fatalf("chain has no FULL backup to derive the root full id from")
return uuid.Nil
}
// chainTipStopLSN is the stop_lsn of the chain's newest backup — the point the
// next incremental's WAL summaries must cover before it can be built.
func chainTipStopLSN(t *testing.T, chain []backups_dto_physical.PhysicalBackupListItem) walmath.LSN {
t.Helper()
require.NotEmpty(t, chain, "chain must hold at least the FULL")
tip := chain[len(chain)-1]
return parseLSN(t, tip.StopLSN)
}
// parseLSN parses a textual LSN carried by a backup list item, failing the test
// on a malformed value.
func parseLSN(t *testing.T, text string) walmath.LSN {
t.Helper()
lsn, err := walmath.ParseLSN(text)
require.NoError(t, err)
return lsn
}
// buildIncrementalViaAPI drives one incremental end to end through the HTTP API:
// it crosses a WAL segment boundary and waits for summaries past parentStopLSN
// (pg_basebackup --incremental needs them), triggers the incremental, and waits
// for the chain to show wantIncrementalsAfter completed incrementals. Returns the
// updated chain.
func buildIncrementalViaAPI(
t *testing.T,
ctx context.Context,
router *gin.Engine,
conn *pgx.Conn,
fixture *postgresql_executor.PhysicalDBFixture,
parentStopLSN walmath.LSN,
wantIncrementalsAfter int,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
_, err := postgresql_executor.GenerateWalActivity(ctx, conn, 32*1024*1024)
require.NoError(t, err)
_, err = conn.Exec(ctx, "CHECKPOINT")
require.NoError(t, err)
_, err = conn.Exec(ctx, "SELECT pg_switch_wal()")
require.NoError(t, err)
require.NoError(t, postgresql_executor.WaitForWalSummaries(ctx, conn, parentStopLSN, 2*time.Minute))
triggerIncrementalViaAPI(t, router, fixture)
return waitForChainBackups(t, router, fixture, wantIncrementalsAfter, 3*time.Minute)
}
// requestRestoreTokenViaAPI issues a restore-stream token for the given PITR
// target (nil ⇒ latest) and returns it.
func requestRestoreTokenViaAPI(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
targetTime *time.Time,
) string {
t.Helper()
var response backups_dto_physical.GenerateRestoreTokenResponse
test_utils.MakePostRequestAndUnmarshal(t, router,
"/api/v1/backups/physical/database/"+fixture.DB.ID.String()+"/restore-token",
"Bearer "+fixture.Owner.Token,
backups_dto_physical.GenerateRestoreTokenRequest{TargetTime: targetTime},
http.StatusOK, &response)
require.NotEmpty(t, response.Token)
return response.Token
}
// requestRestoreTokenExpectingStatus posts a restore-token request expecting a
// specific non-200 status (used by the WAL-gap test, which expects 422) and
// returns the response for body assertions.
func requestRestoreTokenExpectingStatus(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
targetTime *time.Time,
expectedStatus int,
) *test_utils.TestResponse {
t.Helper()
return test_utils.MakePostRequest(t, router,
"/api/v1/backups/physical/database/"+fixture.DB.ID.String()+"/restore-token",
"Bearer "+fixture.Owner.Token,
backups_dto_physical.GenerateRestoreTokenRequest{TargetTime: targetTime},
expectedStatus)
}
// downloadRestoreBundleViaAPI requests a restore token then streams the bundle tar
// from the public restore-stream endpoint to a host temp file, returning its path.
func downloadRestoreBundleViaAPI(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
targetTime *time.Time,
) string {
t.Helper()
token := requestRestoreTokenViaAPI(t, router, fixture, targetTime)
recorder := workspaces_testing.MakeAPIRequest(router, "GET",
"/api/v1/backups/physical/restore-stream?token="+token, "", nil)
require.Equal(t, http.StatusOK, recorder.Code,
"restore-stream must return 200; body: %s", recorder.Body.String())
hostPath := filepath.Join(t.TempDir(), "restore.tar")
require.NoError(t, os.WriteFile(hostPath, recorder.Body.Bytes(), 0o600))
return hostPath
}
// committedWalSegmentsInOrder lists the database's committed (uploaded) WAL
// segments through the flat backup API (type=WAL) and returns them ordered by
// start_lsn ascending. The WAL union only emits rows whose file is uploaded, so
// every listed row is committed — no extra filtering needed.
func committedWalSegmentsInOrder(
t *testing.T,
router *gin.Engine,
fixture *postgresql_executor.PhysicalDBFixture,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
segments := listBackupsByType(t, router, fixture, physical_enums.PhysicalBackupTypeWal)
slices.SortFunc(segments, func(a, b backups_dto_physical.PhysicalBackupListItem) int {
return cmp.Compare(parseLSN(t, a.StartLSN), parseLSN(t, b.StartLSN))
})
return segments
}
// streamPostFullSegments forces WAL rotations until at least minCount committed
// segments lie past fullStopLSN (i.e. in the replayable range after the FULL),
// returning them ordered by start_lsn. pg_receivewal writes full, contiguous
// segments, so the run has no gap until a caller deletes one.
func streamPostFullSegments(
t *testing.T,
ctx context.Context,
router *gin.Engine,
conn *pgx.Conn,
fixture *postgresql_executor.PhysicalDBFixture,
fullStopLSN walmath.LSN,
minCount int,
timeout time.Duration,
) []backups_dto_physical.PhysicalBackupListItem {
t.Helper()
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
_, err := postgresql_executor.ForceWalRotation(ctx, conn)
require.NoError(t, err)
var postFull []backups_dto_physical.PhysicalBackupListItem
for _, segment := range committedWalSegmentsInOrder(t, router, fixture) {
if parseLSN(t, segment.StopLSN) > fullStopLSN {
postFull = append(postFull, segment)
}
}
if len(postFull) >= minCount {
return postFull
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("fewer than %d committed post-FULL WAL segments archived within %s", minCount, timeout)
return nil
}
// createMarkerTable (re)creates the restore_marker table on the source DB, with
// cleanup. Each restore test seeds phase rows here and asserts which survived on
// the restored cluster.
func createMarkerTable(t *testing.T, ctx context.Context, conn *pgx.Conn) {
t.Helper()
_, err := conn.Exec(ctx, `DROP TABLE IF EXISTS restore_marker`)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = conn.Exec(context.Background(), `DROP TABLE IF EXISTS restore_marker`)
})
_, err = conn.Exec(ctx,
`CREATE TABLE restore_marker (phase TEXT PRIMARY KEY, payload TEXT NOT NULL)`)
require.NoError(t, err)
}
func insertMarker(t *testing.T, ctx context.Context, conn *pgx.Conn, phase, payload string) {
t.Helper()
_, err := conn.Exec(ctx,
`INSERT INTO restore_marker (phase, payload) VALUES ($1, $2)`, phase, payload)
require.NoError(t, err)
}
// waitForReplayableThroughLSN blocks until the resolver's latest restore set has a
// contiguous WAL run reaching throughLSN, i.e. the streamed WAL covering the PITR
// target is gap-free and shippable. It drives the same resolver the restore stream
// uses, so it waits on exactly the condition the stream needs.
func waitForReplayableThroughLSN(t *testing.T, databaseID uuid.UUID, throughLSN walmath.LSN, timeout time.Duration) {
t.Helper()
deadline := time.Now().UTC().Add(timeout)
var lastReachable walmath.LSN
for time.Now().UTC().Before(deadline) {
set, err := chain_view.GetChainViewService().ResolveRestoreSet(databaseID, nil)
require.NoError(t, err)
lastReachable = set.MaxReplayableLSN
if set.MaxReplayableLSN >= throughLSN {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("contiguous replayable WAL never reached LSN %s within %s (latest reachable %s)",
throughLSN.String(), timeout, lastReachable.String())
}
// seedChainAndStreamPastTarget seeds the marker table, builds FULL -> INCR -> INCR over the API, then
// streams WAL past a captured target time (with a post-target row that must be dropped). It returns
// the PITR target and the phases that must survive recovery to it - shared by the in-container and the
// entrypoint PITR tests so both drive the identical source history.
func seedChainAndStreamPastTarget(
t *testing.T,
ctx context.Context,
router *gin.Engine,
sourceConn *pgx.Conn,
fixture *postgresql_executor.PhysicalDBFixture,
) (time.Time, []string) {
t.Helper()
createMarkerTable(t, ctx, sourceConn)
insertMarker(t, ctx, sourceConn, "before-full", "row-in-base-backup")
enablePhysicalBackupsViaAPI(t, router, fixture, true)
chain := waitForChainBackups(t, router, fixture, 0, 3*time.Minute)
insertMarker(t, ctx, sourceConn, "after-full", "row-between-full-and-incr1")
chain = buildIncrementalViaAPI(t, ctx, router, sourceConn, fixture, chainTipStopLSN(t, chain), 1)
insertMarker(t, ctx, sourceConn, "after-incr1", "row-between-incr1-and-incr2")
buildIncrementalViaAPI(t, ctx, router, sourceConn, fixture, chainTipStopLSN(t, chain), 2)
// 'before-target' is committed after the last INCR; PITR must replay it from streamed WAL. Fill
// the segment with natural WAL so it rotates and archives (pg_switch_wal would leave a partial
// segment the resolver treats as a gap).
insertMarker(t, ctx, sourceConn, "before-target", "row-replayed-up-to-target")
_, err := postgresql_executor.GenerateWalActivity(ctx, sourceConn, 64*1024*1024)
require.NoError(t, err)
// A margin wider than the whole-second recovery_target_time truncation keeps the cut unambiguous.
time.Sleep(2 * time.Second)
targetTime := time.Now().UTC()
time.Sleep(2 * time.Second)
insertMarker(t, ctx, sourceConn, "after-target", "row-after-target-must-be-absent")
var afterTargetLSN walmath.LSN
require.NoError(t, sourceConn.QueryRow(ctx, `SELECT pg_current_wal_lsn()::text`).Scan(&afterTargetLSN))
_, err = postgresql_executor.GenerateWalActivity(ctx, sourceConn, 64*1024*1024)
require.NoError(t, err)
waitForReplayableThroughLSN(t, fixture.DB.ID, afterTargetLSN, 90*time.Second)
return targetTime, []string{"before-full", "after-full", "after-incr1", "before-target"}
}
// reconstructCluster rebuilds PGDATA the way a user does: it runs the
// server-shipped recovery script (fetched from the public endpoint) inside the
// restore target. The script extracts the bundle, folds the incremental chain with
// pg_combinebackup, decompresses WAL on the host up front and arms PITR from the
// --target-time argument (nil ⇒ latest) — its restore_command is a plain cp, so the
// started cluster needs no zstd. The test drives the real restore path end to end,
// not a reimplementation.
func reconstructCluster(
t *testing.T,
target containers.RestoreTarget,
router *gin.Engine,
image string,
hostBundle string,
targetTime *time.Time,
) {
t.Helper()
hostScript := fetchRecoveryScript(t, router)
// docker cp (testcontainers CopyFileToContainer) cannot write into a tmpfs mount,
// so stage the bundle and script under /tmp; the script runs in-container and
// writes its output into the /restore tmpfs itself, which is fine.
stagedBundle := "/tmp/restore.tar"
stagedScript := "/tmp/databasus-recovery.sh"
target.CopyFileToContainer(t, hostBundle, stagedBundle, 0o644)
target.CopyFileToContainer(t, hostScript, stagedScript, 0o755)
if targetTime != nil {
formattedTarget := targetTime.UTC().Format("2006-01-02 15:04:05-07:00")
target.Exec(t, "sh", stagedScript, "--target-time", formattedTarget, stagedBundle, restoreWorkDir)
} else {
target.Exec(t, "sh", stagedScript, stagedBundle, restoreWorkDir)
}
// pg_ctl runs as the postgres user, so the reconstructed data dir and the WAL
// the restore_command reads must be owned by it.
target.Exec(t, "chown", "-R", "postgres:postgres", restoreWorkDir)
target.Exec(t, "chmod", "0700", restoredClusterDir(image))
}
// fetchRecoveryScript pulls the restore helper from the public endpoint — the same
// bytes the UI tells the user to curl — and writes it to a host temp file.
func fetchRecoveryScript(t *testing.T, router *gin.Engine) string {
t.Helper()
recorder := workspaces_testing.MakeAPIRequest(router, "GET",
"/api/v1/backups/physical/recovery-script", "", nil)
require.Equal(t, http.StatusOK, recorder.Code,
"recovery-script must return 200; body: %s", recorder.Body.String())
require.NotEmpty(t, recorder.Body.Bytes(), "recovery-script must not be empty")
hostPath := filepath.Join(t.TempDir(), "databasus-recovery.sh")
require.NoError(t, os.WriteFile(hostPath, recorder.Body.Bytes(), 0o700))
return hostPath
}
// requireRestoredClusterNeedsNoZstd proves the runtime image needs no zstd: the
// recovery script must have inflated all WAL on the host (no *.zst left in the
// plaintext archive) and wired a plain-cp restore_command, and the cluster must
// then start and replay with the zstd CLI removed. Decompression having already
// happened on the host (during reconstructCluster) is what makes this safe — only
// the runtime dependency is being stripped.
func requireRestoredClusterNeedsNoZstd(t *testing.T, target containers.RestoreTarget, image string) {
t.Helper()
clusterDir := restoredClusterDir(image)
autoConf := string(target.Exec(t, "cat", clusterDir+"/postgresql.auto.conf"))
require.Contains(t, autoConf, "restore_command = 'cp ",
"restore_command must be a plain cp so the runtime cluster never calls zstd")
require.NotContains(t, autoConf, "zstd",
"the recovery config must not reference zstd")
leftoverCompressed := strings.TrimSpace(string(
target.Exec(t, "sh", "-c", "ls "+clusterDir+"/databasus_wal_restore/*.zst 2>/dev/null | wc -l")))
require.Equal(t, "0", leftoverCompressed,
"the WAL archive the cluster reads must hold no still-compressed segments")
// Strip the zstd CLI; libzstd (the server's link-time dep) stays, so PostgreSQL
// still starts — only an on-demand restore_command would now fail.
target.Exec(t, "sh", "-c",
`p=$(command -v zstd); [ -n "$p" ] && rm -f "$p"; ! command -v zstd >/dev/null`)
}
func startRestoredCluster(t *testing.T, target containers.RestoreTarget, image string) {
t.Helper()
clusterDir := restoredClusterDir(image)
target.Exec(t, "sh", "-c",
"touch "+restoreWorkDir+"/pg.log && chown postgres:postgres "+restoreWorkDir+"/pg.log")
// Surface the server log on failure — recovery errors are otherwise invisible
// behind pg_ctl's generic "could not start server".
t.Cleanup(func() {
if !t.Failed() {
return
}
t.Logf("=== restored pg.log ===\n%s", target.ExecBestEffort("", "cat", restoreWorkDir+"/pg.log"))
})
target.ExecAs(t, "postgres",
"pg_ctl", "-D", clusterDir, "-l", restoreWorkDir+"/pg.log", "-w", "start")
t.Cleanup(func() {
target.ExecBestEffort("postgres",
"pg_ctl", "-D", clusterDir, "-m", "immediate", "stop")
})
}
func queryRestoredMarkerRows(t *testing.T, target containers.RestoreTarget) []string {
t.Helper()
return queryMarkerRowsAt(t, target.Host(), target.MappedPort())
}
// queryMarkerRowsAt reads the restore_marker phases from a restored cluster at host:port - whether it
// was started by pg_ctl in the restore target or by the postgres entrypoint on a bound volume.
func queryMarkerRowsAt(t *testing.T, host string, port int) []string {
t.Helper()
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, restoredPgUser, restoredPgPassword, restoredPgDatabase)
conn := connectWithRetry(t, dsn, 60*time.Second)
defer func() { _ = conn.Close(t.Context()) }()
rows, err := conn.Query(t.Context(), `SELECT phase FROM restore_marker ORDER BY phase`)
require.NoError(t, err)
defer rows.Close()
var phases []string
for rows.Next() {
var phase string
require.NoError(t, rows.Scan(&phase))
phases = append(phases, phase)
}
require.NoError(t, rows.Err())
return phases
}
func openSourceTestDBConn(t *testing.T, fixture *postgresql_executor.PhysicalDBFixture) *pgx.Conn {
t.Helper()
return openConnAt(t, fixture.DB.PostgresqlPhysical.Host, fixture.DB.PostgresqlPhysical.Port)
}
// openConnAt opens a superuser connection (the seeded testuser) to a PostgreSQL endpoint, closed on
// cleanup. The cross-timeline test reaches the primary through this, since the fixture's own
// connection targets the standby Databasus backs up.
func openConnAt(t *testing.T, host string, port int) *pgx.Conn {
t.Helper()
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
host, port, restoredPgUser, restoredPgPassword, restoredPgDatabase)
conn, err := pgx.Connect(t.Context(), dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close(context.Background()) })
return conn
}
// waitForMarkerOnStandby blocks until phase is visible on the standby, i.e. streaming replication
// has carried the primary's write across. The FULL is taken from the standby, so a row must land
// there before it can appear in the base backup. The marker table itself replicates via WAL too, so
// a query before it exists errors with undefined_table — treated here as "not yet replicated".
func waitForMarkerOnStandby(
t *testing.T,
ctx context.Context,
standbyConn *pgx.Conn,
phase string,
timeout time.Duration,
) {
t.Helper()
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
var exists bool
if err := standbyConn.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM restore_marker WHERE phase = $1)`, phase,
).Scan(&exists); err == nil && exists {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatalf("marker %q never replicated to the standby within %s", phase, timeout)
}
// PromoteStandby promotes a streaming standby to a primary on a fresh timeline and waits until it
// has left recovery. pg_promote keeps the postmaster up, so a pg_receivewal already streaming from
// this node follows the switch and fetches the new timeline's .history file — the path issue #643
// regressed. Callers can write on the new timeline as soon as it returns.
func PromoteStandby(t *testing.T, ctx context.Context, standbyConn *pgx.Conn) {
t.Helper()
var promoted bool
require.NoError(t, standbyConn.QueryRow(ctx,
`SELECT pg_promote(wait => true, wait_seconds => 60)`).Scan(&promoted))
require.True(t, promoted, "pg_promote must finish promotion within its wait window")
deadline := time.Now().UTC().Add(30 * time.Second)
for time.Now().UTC().Before(deadline) {
var isInRecovery bool
require.NoError(t, standbyConn.QueryRow(ctx, `SELECT pg_is_in_recovery()`).Scan(&isInRecovery))
if !isInRecovery {
return
}
time.Sleep(250 * time.Millisecond)
}
t.Fatal("standby still in recovery 30s after pg_promote reported success")
}
// waitForTimelineHistoryOnParent blocks until the WAL stream supervisor has cataloged the timeline's
// .history row, then asserts it is keyed on the parent databases.id and not the
// postgresql_physical_databases PK. Pre-fix (issue #643) the insert violated
// fk_physical_wal_history_files_database_id and the row never appeared, so this both proves the
// failover path runs end to end and guards the regression.
func waitForTimelineHistoryOnParent(
t *testing.T,
fixture *postgresql_executor.PhysicalDBFixture,
timelineID int,
timeout time.Duration,
) {
t.Helper()
historyRepo := physical_repositories.GetWalHistoryRepository()
parentDatabaseID := fixture.DB.ID
physicalPK := fixture.DB.PostgresqlPhysical.ID
deadline := time.Now().UTC().Add(timeout)
for time.Now().UTC().Before(deadline) {
byParent, err := historyRepo.FindByDatabaseTimeline(parentDatabaseID, timelineID)
require.NoError(t, err)
if byParent != nil {
byPhysicalPK, err := historyRepo.FindByDatabaseTimeline(physicalPK, timelineID)
require.NoError(t, err)
require.Nil(t, byPhysicalPK,
"timeline-%d history must be keyed on the parent databases.id, not the physical PK", timelineID)
return
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("WAL stream supervisor never cataloged the timeline-%d history row on the parent databases.id "+
"within %s (issue #643: keying on the physical PK fails fk_physical_wal_history_files_database_id)",
timelineID, timeout)
}
func connectWithRetry(t *testing.T, dsn string, timeout time.Duration) *pgx.Conn {
t.Helper()
deadline := time.Now().UTC().Add(timeout)
var lastErr error
for time.Now().UTC().Before(deadline) {
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
conn, err := pgx.Connect(ctx, dsn)
cancel()
if err == nil {
return conn
}
lastErr = err
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("could not connect to restored PG within %s: %v", timeout, lastErr)
return nil
}