283 lines
8.5 KiB
Python
283 lines
8.5 KiB
Python
"""A dump from a newer engine must be refused before --empty destroys anything.
|
|||
|
|
|
||
|
|
The pre-clean and the replay are two separate sessions with no rollback across
|
||
|
|
them, so a dump the engine cannot parse leaves an emptied database behind. The
|
||
|
|
decisive assertion here is not the non-zero exit - it is that the payload is
|
||
|
|
still readable afterwards.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import re
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from .helpers import (
|
||
|
|
MARIADB_DATA_DIR,
|
||
|
|
MARIADB_IMAGE,
|
||
|
|
POSTGRES_DATA_DIR,
|
||
|
|
POSTGRES_IMAGE,
|
||
|
|
backup_path,
|
||
|
|
backup_run,
|
||
|
|
cleanup_docker,
|
||
|
|
create_minimal_compose_dir,
|
||
|
|
ensure_empty_dir,
|
||
|
|
latest_version_dir,
|
||
|
|
require_docker,
|
||
|
|
run,
|
||
|
|
unique,
|
||
|
|
wait_for_mariadb,
|
||
|
|
wait_for_mariadb_sql,
|
||
|
|
wait_for_postgres,
|
||
|
|
write_databases_csv,
|
||
|
|
)
|
||
|
|
|
||
|
|
PAYLOAD = "gate-payload"
|
||
|
|
FUTURE = "99.0"
|
||
|
|
|
||
|
|
|
||
|
|
def rewrite_version(dump: Path, pattern: str, version: str) -> str:
|
||
|
|
"""Make the dump claim ``version``; return what it claimed before."""
|
||
|
|
text = dump.read_text(encoding="utf-8", errors="replace")
|
||
|
|
found = re.search(pattern, text)
|
||
|
|
if not found:
|
||
|
|
raise AssertionError(f"{dump} carries no version header matching {pattern}")
|
||
|
|
claimed = found.group(1)
|
||
|
|
dump.write_text(
|
||
|
|
text.replace(found.group(0), found.group(0).replace(claimed, version), 1),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
return claimed
|
||
|
|
|
||
|
|
|
||
|
|
class GateCase:
|
||
|
|
"""Drive one engine through refusal, escape hatch and truthful replay."""
|
||
|
|
|
||
|
|
engine = ""
|
||
|
|
pattern = ""
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def restore(cls, *extra: str):
|
||
|
|
return run(
|
||
|
|
[
|
||
|
|
"baudolo-restore",
|
||
|
|
cls.engine,
|
||
|
|
cls.volume,
|
||
|
|
cls.hash,
|
||
|
|
cls.version,
|
||
|
|
"--backups-dir",
|
||
|
|
cls.backups_dir,
|
||
|
|
"--repo-name",
|
||
|
|
cls.repo_name,
|
||
|
|
"--container",
|
||
|
|
cls.container,
|
||
|
|
"--db-name",
|
||
|
|
cls.db_name,
|
||
|
|
"--db-user",
|
||
|
|
cls.db_user,
|
||
|
|
"--db-password",
|
||
|
|
cls.db_password,
|
||
|
|
"--empty",
|
||
|
|
*extra,
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def prepare(cls) -> None:
|
||
|
|
cls.backups_dir = f"/tmp/{cls.prefix}/Backups"
|
||
|
|
ensure_empty_dir(cls.backups_dir)
|
||
|
|
cls.compose_dir = create_minimal_compose_dir(f"/tmp/{cls.prefix}")
|
||
|
|
cls.repo_name = cls.prefix
|
||
|
|
cls.databases_csv = f"/tmp/{cls.prefix}/databases.csv"
|
||
|
|
write_databases_csv(
|
||
|
|
cls.databases_csv,
|
||
|
|
[(cls.container, cls.db_name, cls.db_user, cls.db_password)],
|
||
|
|
)
|
||
|
|
backup_run(
|
||
|
|
backups_dir=cls.backups_dir,
|
||
|
|
repo_name=cls.repo_name,
|
||
|
|
compose_dir=cls.compose_dir,
|
||
|
|
databases_csv=cls.databases_csv,
|
||
|
|
database_containers=[cls.container],
|
||
|
|
images_no_stop_required=[cls.image],
|
||
|
|
)
|
||
|
|
cls.hash, cls.version = latest_version_dir(cls.backups_dir, cls.repo_name)
|
||
|
|
cls.dump = (
|
||
|
|
backup_path(cls.backups_dir, cls.repo_name, cls.version, cls.volume)
|
||
|
|
/ "sql"
|
||
|
|
/ f"{cls.db_name}.backup.sql"
|
||
|
|
)
|
||
|
|
|
||
|
|
cls.truthful_version = rewrite_version(cls.dump, cls.pattern, FUTURE)
|
||
|
|
cls.refused = cls.restore()
|
||
|
|
cls.payload_after_refusal = cls.read_payload()
|
||
|
|
|
||
|
|
cls.forced = cls.restore("--no-version-check")
|
||
|
|
cls.payload_after_force = cls.read_payload()
|
||
|
|
|
||
|
|
rewrite_version(cls.dump, cls.pattern, cls.truthful_version)
|
||
|
|
cls.replayed = cls.restore()
|
||
|
|
cls.payload_after_replay = cls.read_payload()
|
||
|
|
|
||
|
|
def test_the_dump_states_the_engine_it_came_from(self) -> None:
|
||
|
|
self.assertRegex(self.truthful_version, r"^\d+")
|
||
|
|
|
||
|
|
def test_a_newer_dump_is_refused(self) -> None:
|
||
|
|
self.assertNotEqual(self.refused.returncode, 0, self.refused.stdout)
|
||
|
|
|
||
|
|
def test_the_refusal_names_the_version_it_refused(self) -> None:
|
||
|
|
self.assertIn(FUTURE, self.refused.stderr)
|
||
|
|
self.assertIn("older engine", self.refused.stderr)
|
||
|
|
|
||
|
|
def test_the_refusal_left_the_data_untouched(self) -> None:
|
||
|
|
self.assertEqual(
|
||
|
|
self.payload_after_refusal,
|
||
|
|
PAYLOAD,
|
||
|
|
"--empty pre-cleaned before the version was checked",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_the_escape_hatch_replays_anyway(self) -> None:
|
||
|
|
self.assertEqual(self.forced.returncode, 0, self.forced.stderr)
|
||
|
|
self.assertEqual(self.payload_after_force, PAYLOAD)
|
||
|
|
|
||
|
|
def test_a_truthful_dump_replays(self) -> None:
|
||
|
|
self.assertEqual(self.replayed.returncode, 0, self.replayed.stderr)
|
||
|
|
self.assertEqual(self.payload_after_replay, PAYLOAD)
|
||
|
|
|
||
|
|
|
||
|
|
class TestE2EPostgresVersionGate(GateCase, unittest.TestCase):
|
||
|
|
engine = "postgres"
|
||
|
|
pattern = r"-- Dumped from database version (\S+)"
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls) -> None:
|
||
|
|
require_docker()
|
||
|
|
cls.prefix = unique("baudolo-e2e-pg-gate")
|
||
|
|
cls.container = f"{cls.prefix}-pg"
|
||
|
|
cls.volume = f"{cls.prefix}-pg-vol"
|
||
|
|
cls.image = POSTGRES_IMAGE
|
||
|
|
cls.db_name = "appdb"
|
||
|
|
cls.db_user = "postgres"
|
||
|
|
cls.db_password = "pgpw"
|
||
|
|
|
||
|
|
run(["docker", "volume", "create", cls.volume])
|
||
|
|
run(
|
||
|
|
[
|
||
|
|
"docker",
|
||
|
|
"run",
|
||
|
|
"-d",
|
||
|
|
"--name",
|
||
|
|
cls.container,
|
||
|
|
"-e",
|
||
|
|
f"POSTGRES_PASSWORD={cls.db_password}",
|
||
|
|
"-v",
|
||
|
|
f"{cls.volume}:{POSTGRES_DATA_DIR}",
|
||
|
|
POSTGRES_IMAGE,
|
||
|
|
]
|
||
|
|
)
|
||
|
|
wait_for_postgres(cls.container, user=cls.db_user)
|
||
|
|
cls.sql("postgres", f"CREATE DATABASE {cls.db_name}")
|
||
|
|
cls.sql(
|
||
|
|
cls.db_name,
|
||
|
|
f"CREATE TABLE t (v text); INSERT INTO t VALUES ('{PAYLOAD}');",
|
||
|
|
)
|
||
|
|
cls.prepare()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def tearDownClass(cls) -> None:
|
||
|
|
cleanup_docker(containers=[cls.container], volumes=[cls.volume])
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def sql(cls, database: str, statement: str) -> str:
|
||
|
|
p = run(
|
||
|
|
[
|
||
|
|
"docker",
|
||
|
|
"exec",
|
||
|
|
cls.container,
|
||
|
|
"sh",
|
||
|
|
"-lc",
|
||
|
|
f'psql -U {cls.db_user} -d {database} -t -A -c "{statement}"',
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
return (p.stdout or "").strip()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def read_payload(cls) -> str:
|
||
|
|
return cls.sql(cls.db_name, "SELECT v FROM t")
|
||
|
|
|
||
|
|
|
||
|
|
class TestE2EMariadbVersionGate(GateCase, unittest.TestCase):
|
||
|
|
engine = "mariadb"
|
||
|
|
pattern = r"-- Server version\s+(\S+)"
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def setUpClass(cls) -> None:
|
||
|
|
require_docker()
|
||
|
|
cls.prefix = unique("baudolo-e2e-mdb-gate")
|
||
|
|
cls.container = f"{cls.prefix}-mdb"
|
||
|
|
cls.volume = f"{cls.prefix}-mdb-vol"
|
||
|
|
cls.image = MARIADB_IMAGE
|
||
|
|
cls.db_name = "appdb"
|
||
|
|
cls.db_user = "test"
|
||
|
|
cls.db_password = "testpw"
|
||
|
|
|
||
|
|
run(["docker", "volume", "create", cls.volume])
|
||
|
|
run(
|
||
|
|
[
|
||
|
|
"docker",
|
||
|
|
"run",
|
||
|
|
"-d",
|
||
|
|
"--name",
|
||
|
|
cls.container,
|
||
|
|
"-e",
|
||
|
|
"MARIADB_ROOT_PASSWORD=rootpw",
|
||
|
|
"-e",
|
||
|
|
f"MARIADB_DATABASE={cls.db_name}",
|
||
|
|
"-e",
|
||
|
|
f"MARIADB_USER={cls.db_user}",
|
||
|
|
"-e",
|
||
|
|
f"MARIADB_PASSWORD={cls.db_password}",
|
||
|
|
"-v",
|
||
|
|
f"{cls.volume}:{MARIADB_DATA_DIR}",
|
||
|
|
MARIADB_IMAGE,
|
||
|
|
]
|
||
|
|
)
|
||
|
|
wait_for_mariadb(cls.container, root_password="rootpw", timeout_s=90)
|
||
|
|
wait_for_mariadb_sql(
|
||
|
|
cls.container, user=cls.db_user, password=cls.db_password, timeout_s=90
|
||
|
|
)
|
||
|
|
cls.sql(
|
||
|
|
f"CREATE TABLE {cls.db_name}.t (v VARCHAR(50)); "
|
||
|
|
f"INSERT INTO {cls.db_name}.t VALUES ('{PAYLOAD}');"
|
||
|
|
)
|
||
|
|
cls.prepare()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def tearDownClass(cls) -> None:
|
||
|
|
cleanup_docker(containers=[cls.container], volumes=[cls.volume])
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def sql(cls, statement: str) -> str:
|
||
|
|
p = run(
|
||
|
|
[
|
||
|
|
"docker",
|
||
|
|
"exec",
|
||
|
|
cls.container,
|
||
|
|
"sh",
|
||
|
|
"-lc",
|
||
|
|
(
|
||
|
|
f"mariadb -h 127.0.0.1 -u{cls.db_user} -p{cls.db_password} "
|
||
|
|
f'-N -B -e "{statement}"'
|
||
|
|
),
|
||
|
|
],
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
return (p.stdout or "").strip()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def read_payload(cls) -> str:
|
||
|
|
return cls.sql(f"SELECT v FROM {cls.db_name}.t")
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|