mirror of
https://github.com/kevinveenbirkenbach/docker-volume-backup.git
synced 2026-08-20 13:12:48 +00:00
fix(restore): refuse a cluster restore that would destroy what it cannot restore
The --empty pre-clean is a catalog-wide sweep: it drops every non-template database and every non-pg_ role of the instance. On a dedicated instance that is exactly right, because the dump recreates all of it. On a shared one it destroys databases the dump does not carry, with nothing to restore them from - and no test ever executed that sweep, because the e2e dropped the cluster by hand first and left the pre-clean with zero rows to generate. Scoping the sweep to the dump's own inventory looks like the fix and is worse. A surviving database that owns or merely grants to one of the dump's roles pins that role in pg_shdepend; DROP OWNED BY only reaches the control database the pre-clean is connected to, so DROP ROLE fails - after phase 1 has already dropped the dump's databases. ON_ERROR_STOP aborts, the replay never starts, and the instance is left half emptied. So the instance is checked instead. --empty now refuses when the instance holds a database the dump does not carry, names it, and touches nothing. The sweep stays as it was, safe behind that refusal. Reading the dump's inventory needs a real identifier parser: a quoted name may hold spaces, and psql options precede the target of a \\connect line. The e2e no longer drops the cluster itself, so --empty has to do it and the replay has to put it back; a second pass then adds a foreign database and requires the refusal to leave both it and the restored data alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,11 @@ SEED_SQL = (
|
||||
"CREATE DATABASE first OWNER app",
|
||||
"CREATE DATABASE second OWNER app",
|
||||
)
|
||||
DROP_SQL = (
|
||||
"DROP DATABASE first",
|
||||
"DROP DATABASE second",
|
||||
"DROP ROLE app",
|
||||
SIBLING_SQL = (
|
||||
"CREATE ROLE neighbour LOGIN PASSWORD 'neighbourpw'",
|
||||
"CREATE DATABASE sibling OWNER neighbour",
|
||||
)
|
||||
SIBLING_PAYLOAD = "CREATE TABLE t (v text); INSERT INTO t VALUES ('sibling-payload');"
|
||||
FIRST_SQL = "CREATE TABLE t (v text); INSERT INTO t VALUES ('first-payload');"
|
||||
SECOND_SQL = "CREATE TABLE t (v text); INSERT INTO t VALUES ('second-payload');"
|
||||
|
||||
@@ -89,9 +89,6 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
|
||||
/ f"{cls.pg_container}.cluster.backup.sql"
|
||||
)
|
||||
|
||||
for statement in DROP_SQL:
|
||||
cls._psql("postgres", statement)
|
||||
|
||||
run(
|
||||
[
|
||||
"baudolo-restore",
|
||||
@@ -115,6 +112,34 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
|
||||
]
|
||||
)
|
||||
|
||||
for statement in SIBLING_SQL:
|
||||
cls._psql("postgres", statement)
|
||||
cls._psql("sibling", SIBLING_PAYLOAD)
|
||||
cls.refused = run(cls._restore_argv(), check=False)
|
||||
|
||||
@classmethod
|
||||
def _restore_argv(cls) -> list:
|
||||
return [
|
||||
"baudolo-restore",
|
||||
"cluster",
|
||||
cls.pg_volume,
|
||||
cls.hash,
|
||||
cls.version,
|
||||
"--backups-dir",
|
||||
cls.backups_dir,
|
||||
"--repo-name",
|
||||
cls.repo_name,
|
||||
"--container",
|
||||
cls.pg_container,
|
||||
"--instance",
|
||||
cls.pg_container,
|
||||
"--db-user",
|
||||
"postgres",
|
||||
"--db-password",
|
||||
"pgpw",
|
||||
"--empty",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cleanup_docker(containers=cls.containers, volumes=cls.volumes)
|
||||
@@ -136,6 +161,19 @@ class TestE2EPostgresClusterRestore(unittest.TestCase):
|
||||
def test_the_backup_wrote_a_cluster_dump(self) -> None:
|
||||
self.assertTrue(self.dump.is_file(), f"no cluster dump at {self.dump}")
|
||||
|
||||
def test_the_preclean_really_dropped_a_populated_cluster(self) -> None:
|
||||
self.assertEqual(self._psql("first", "SELECT v FROM t"), "first-payload")
|
||||
|
||||
def test_a_second_empty_is_refused_once_a_foreign_database_exists(self) -> None:
|
||||
self.assertNotEqual(self.refused.returncode, 0, self.refused.stdout)
|
||||
self.assertIn("sibling", self.refused.stderr)
|
||||
|
||||
def test_the_refusal_left_the_foreign_database_alone(self) -> None:
|
||||
self.assertEqual(self._psql("sibling", "SELECT v FROM t"), "sibling-payload")
|
||||
|
||||
def test_the_refusal_dropped_nothing_of_its_own(self) -> None:
|
||||
self.assertEqual(self._psql("first", "SELECT v FROM t"), "first-payload")
|
||||
|
||||
def test_both_databases_are_back(self) -> None:
|
||||
listed = self._psql(
|
||||
"postgres",
|
||||
|
||||
Reference in New Issue
Block a user