diff --git a/plugins/lookup/database_consumerless_volumes.py b/plugins/lookup/database_consumerless_volumes.py new file mode 100644 index 0000000000..c18e3a557d --- /dev/null +++ b/plugins/lookup/database_consumerless_volumes.py @@ -0,0 +1,72 @@ +"""Name the data volume of every database provider on this host nobody consumes. + +A consumer is a deployed role whose merged config enables a shared database of +the provider's engine; a role with a dedicated database brings its own volume +and is no consumer of the provider. A provider without a consumer holds only +the databases its engine creates for itself, so no databases.csv row names it +and baudolo records its volume undumped. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ansible.errors import AnsibleError +from ansible.plugins.loader import lookup_loader +from ansible.plugins.lookup import LookupBase + +from utils.roles.applications.services.database import ( + RDBMS_SERVICE_KEYS, + get_database_service_config, + resolve_database_service_key, +) + +if TYPE_CHECKING: + from collections.abc import Sequence + +PROVIDER_VOLUME = "data" + + +def provider_of(engine: str) -> str: + return f"svc-db-{engine}" + + +class LookupModule(LookupBase): + def run( + self, + terms: Sequence[Any] | None, + variables: dict[str, Any] | None = None, + **kwargs: Any, + ) -> list[Any]: + if terms: + raise AnsibleError("database_consumerless_volumes: expects no terms.") + + vars_ = variables or getattr(self._templar, "available_variables", {}) or {} + group_names = [str(group) for group in vars_.get("group_names") or []] + templar = getattr(self, "_templar", None) + applications = lookup_loader.get( + "applications", loader=self._loader, templar=templar + ).run([], variables=vars_)[0] + volume = lookup_loader.get("volume", loader=self._loader, templar=templar) + + providers = {provider_of(engine) for engine in RDBMS_SERVICE_KEYS} + consumed: set[str] = set() + for role_id in group_names: + if role_id in providers: + continue + try: + engine = resolve_database_service_key(applications, role_id) + except ValueError as error: + raise AnsibleError(f"database_consumerless_volumes: {error}") from error + if engine and get_database_service_config(applications, role_id).get( + "shared" + ): + consumed.add(provider_of(engine)) + + return [ + [ + volume.run([provider, PROVIDER_VOLUME], variables=vars_)[0]["name"] + for provider in sorted(providers) + if provider in group_names and provider not in consumed + ] + ] diff --git a/roles/svc-bkp-volume-2-local/files/test/verify/backup.sh b/roles/svc-bkp-volume-2-local/files/test/verify/backup.sh index bae07a2ad0..8186ff0733 100755 --- a/roles/svc-bkp-volume-2-local/files/test/verify/backup.sh +++ b/roles/svc-bkp-volume-2-local/files/test/verify/backup.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Verify the newest backup generation is stored, non-empty, and dumped where a # dump was owed: a volume counts as payload with a files/ tree or a non-empty -# sql dump, and a volume the run recorded as a database must carry the dump. +# sql dump, and a volume the run recorded as a database must carry the dump +# unless its provider has no consumer on this host. # The generation's own manifest is the authority; a generation written before # baudolo wrote one is judged by the engine's on-disk footprint instead. # Requires REPO_DIR/NEWEST_GENERATION/BKP_TEST_REPO_ROOT from test.sh. @@ -11,6 +12,7 @@ set -euo pipefail : "${NEWEST_GENERATION:?}" : "${BKP_TEST_REPO_ROOT:?}" : "${BKP_TEST_PYTHON:?}" +: "${BKP_TEST_CONSUMERLESS_DB_VOLUMES?}" GEN_DIR="${REPO_DIR}/${NEWEST_GENERATION}" @@ -39,6 +41,19 @@ engine_of() { return 1 } +read -r -a CONSUMERLESS <<< "${BKP_TEST_CONSUMERLESS_DB_VOLUMES}" + +owe_dump() { + local volume="$1" engine="$2" idle + for idle in "${CONSUMERLESS[@]}"; do + if [[ "${idle}" == "${volume}" ]]; then + echo "NOTE: ${volume} (${engine}) belongs to a database provider no deployed app consumes; nothing owes a dump" + return 0 + fi + done + UNDUMPED+=("${volume} (${engine})") +} + MANIFEST_LINES="" if MANIFEST_LINES="$(PYTHONPATH="${BKP_TEST_REPO_ROOT}" "${BKP_TEST_PYTHON}" -m utils.recovery.manifest "${GEN_DIR}")"; then FROM_MANIFEST=true @@ -61,14 +76,14 @@ for vol_dir in "${VOLUME_DIRS[@]}"; do if [[ "${FROM_MANIFEST}" == "false" ]] \ && engine="$(engine_of "${vol_dir}/files")" \ && [[ -z "${has_sql}" ]]; then - UNDUMPED+=("${vol_dir##*/} (${engine})") + owe_dump "${vol_dir##*/}" "${engine}" fi done if [[ "${FROM_MANIFEST}" == "true" ]] && [[ -n "${MANIFEST_LINES}" ]]; then while IFS=$'\t' read -r volume engine; do if [[ -n "${volume}" ]]; then - UNDUMPED+=("${volume} (${engine})") + owe_dump "${volume}" "${engine}" fi done <<< "${MANIFEST_LINES}" fi diff --git a/roles/svc-bkp-volume-2-local/templates/test.env.j2 b/roles/svc-bkp-volume-2-local/templates/test.env.j2 index e28d7de49c..e534ec3611 100644 --- a/roles/svc-bkp-volume-2-local/templates/test.env.j2 +++ b/roles/svc-bkp-volume-2-local/templates/test.env.j2 @@ -1,5 +1,6 @@ ASYNC_ENABLED={{ ASYNC_ENABLED | bool | lower }} BKP_TEST_BACKUPS_DIR={{ DIR_BACKUPS }} +BKP_TEST_CONSUMERLESS_DB_VOLUMES={{ lookup('database_consumerless_volumes') | join(' ') | dotenv_quote }} BKP_TEST_HEALTH_SCRIPT={{ [ DIR_SCRIPTS_SYSTEMCTL, 'sys-ctl-hlth-container', 'script.sh' ] | path_join }} BKP_TEST_HEALTH_TIMEOUT={{ lookup('timeout', 1200) | int }} BKP_TEST_IS_STACK_HOST={{ IS_STACK_HOST | bool | lower }} diff --git a/tests/integration/ansible/lookups/test_database_consumerless_volumes.py b/tests/integration/ansible/lookups/test_database_consumerless_volumes.py new file mode 100644 index 0000000000..556a41b588 --- /dev/null +++ b/tests/integration/ansible/lookups/test_database_consumerless_volumes.py @@ -0,0 +1,70 @@ +"""Integration test: database_consumerless_volumes against the real role tree. + +The unit tests hand the lookup a literal ``shared: true``. The roles declare it +as ``{{ 'svc-db-postgres' in group_names }}``, so only a render through the real +applications lookup shows whether the predicate sees the rendered boolean. +""" + +from __future__ import annotations + +import unittest + +from ansible.parsing.dataloader import DataLoader +from ansible.template import Templar + +from plugins.lookup.applications import LookupModule as ApplicationsLookup +from plugins.lookup.applications import _reset_cache_for_tests +from plugins.lookup.database_consumerless_volumes import ( + LookupModule as ConsumerlessLookup, +) + +from . import PROJECT_ROOT + +ROLES_DIR = PROJECT_ROOT / "roles" +LEAN = ["svc-bkp-volume-2-local", "svc-db-postgres", "svc-net-tor", "web-svc-mirror"] + + +def _variables(applications: dict, group_names: list[str]) -> dict: + return { + "applications": applications, + "users": {}, + "DOMAIN_PRIMARY": "infinito.test", + "SYSTEM_EMAIL_DOMAIN": "infinito.test", + "DIR_COMPOSITIONS": "/opt/compose/", + "group_names": list(group_names), + } + + +def _consumerless(group_names: list[str]) -> list[str]: + _reset_cache_for_tests() + applications = ApplicationsLookup() + applications._templar = Templar(loader=DataLoader()) + merged = applications.run( + [], variables=_variables({}, group_names), roles_dir=str(ROLES_DIR) + )[0] + lookup = ConsumerlessLookup() + lookup._loader = DataLoader() + lookup._templar = Templar(loader=lookup._loader) + return lookup.run([], variables=_variables(merged, group_names))[0] + + +class TestDatabaseConsumerlessVolumesIntegration(unittest.TestCase): + @classmethod + def tearDownClass(cls) -> None: + _reset_cache_for_tests() + + def test_the_lean_postgres_variant_leaves_its_volume_unconsumed(self) -> None: + self.assertEqual(_consumerless(LEAN), ["postgres_data"]) + + def test_a_shared_postgres_consumer_claims_the_volume(self) -> None: + self.assertEqual(_consumerless([*LEAN, "web-app-mastodon"]), []) + + def test_a_mariadb_consumer_leaves_postgres_unconsumed(self) -> None: + self.assertEqual( + _consumerless([*LEAN, "svc-db-mariadb", "web-app-matomo"]), + ["postgres_data"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_database_consumerless_volumes.py b/tests/unit/python/plugins/lookup/test_database_consumerless_volumes.py new file mode 100644 index 0000000000..fb58888587 --- /dev/null +++ b/tests/unit/python/plugins/lookup/test_database_consumerless_volumes.py @@ -0,0 +1,117 @@ +"""Unit tests for ``plugins/lookup/database_consumerless_volumes.py``. + +Pins the consumer predicate: a deployed role consumes a provider when it +enables a shared database of that provider's engine. A dedicated database, a +provider itself, or a consumer of the other engine never counts. +""" + +import importlib.util +import unittest +import unittest.mock as mock + +from ansible.errors import AnsibleError + +from . import PROJECT_ROOT + + +def _load_module(rel_path: str, name: str): + path = PROJECT_ROOT / rel_path + spec = importlib.util.spec_from_file_location(name, str(path)) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(mod) + return mod + + +def _database(engine: str, *, shared: bool = True) -> dict: + return {"services": {engine: {"enabled": True, "shared": shared}}} + + +class DatabaseConsumerlessVolumesTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.mod = _load_module( + "plugins/lookup/database_consumerless_volumes.py", + "lookup_database_consumerless_volumes", + ) + + def setUp(self): + self._original_loader = self.mod.lookup_loader + self._applications: dict = {} + + def _get(name, **_kwargs): + if name == "applications": + return mock.MagicMock(run=lambda *_a, **_k: [self._applications]) + return mock.MagicMock(run=self._volume_run) + + loader_mock = mock.MagicMock() + loader_mock.get.side_effect = _get + self.mod.lookup_loader = loader_mock + + def tearDown(self): + self.mod.lookup_loader = self._original_loader + + @staticmethod + def _volume_run(terms, **_kwargs): + provider, semantic = terms + return [{"name": f"{provider.removeprefix('svc-db-')}_{semantic}"}] + + def _run(self, group_names, applications, terms=()): + self._applications = applications + lk = self.mod.LookupModule() + lk._loader = mock.MagicMock() + lk._templar = mock.MagicMock() + return lk.run(list(terms), variables={"group_names": list(group_names)}) + + def test_terms_are_refused(self): + with self.assertRaises(AnsibleError): + self._run([], {}, terms=("postgres",)) + + def test_a_provider_without_a_consumer_is_named(self): + result = self._run( + ["svc-db-postgres", "web-svc-mirror"], + {"svc-db-postgres": _database("postgres")}, + ) + self.assertEqual(result, [["postgres_data"]]) + + def test_a_shared_consumer_claims_its_provider(self): + result = self._run( + ["svc-db-postgres", "web-app-matomo"], + {"web-app-matomo": _database("postgres")}, + ) + self.assertEqual(result, [[]]) + + def test_a_dedicated_database_claims_no_provider(self): + result = self._run( + ["svc-db-postgres", "web-app-a"], + {"web-app-a": _database("postgres", shared=False)}, + ) + self.assertEqual(result, [["postgres_data"]]) + + def test_a_consumer_claims_only_its_own_engine(self): + result = self._run( + ["svc-db-mariadb", "svc-db-postgres", "web-app-matomo"], + {"web-app-matomo": _database("mariadb")}, + ) + self.assertEqual(result, [["postgres_data"]]) + + def test_a_provider_that_is_not_deployed_is_not_named(self): + self.assertEqual(self._run(["web-app-a"], {}), [[]]) + + def test_a_role_with_two_database_services_fails_loudly(self): + with self.assertRaises(AnsibleError): + self._run( + ["svc-db-postgres", "web-app-a"], + { + "web-app-a": { + "services": { + "mariadb": {"enabled": True, "shared": True}, + "postgres": {"enabled": True, "shared": True}, + } + } + }, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/roles/svc-bkp-volume-2-local/files/test_verify_backup.py b/tests/unit/python/roles/svc-bkp-volume-2-local/files/test_verify_backup.py new file mode 100644 index 0000000000..038d2b093f --- /dev/null +++ b/tests/unit/python/roles/svc-bkp-volume-2-local/files/test_verify_backup.py @@ -0,0 +1,92 @@ +"""verify/backup.sh owes no dump for a provider nobody on the host consumes. + +A database provider deployed without a consumer holds only the databases its +engine creates for itself. No databases.csv row names it, so baudolo copies the +volume live and records it undumped, and the drill failed on data nobody wrote. +Every other undumped database volume, above all one whose consumer forgot to +seed its row, stays a failure. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from utils.recovery.layout import FILES_DIR, MANIFEST_FILE, MANIFEST_SCHEMA, SQL_DIR + +from . import PROJECT_ROOT + +SCRIPT = ( + PROJECT_ROOT + / "roles" + / "svc-bkp-volume-2-local" + / "files" + / "test" + / "verify" + / "backup.sh" +) +GENERATION = "20260922135655" + + +def _verify( + volume: str, engine: str, consumerless: str +) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory() as repo: + generation = Path(repo) / GENERATION + files = generation / volume / FILES_DIR + files.mkdir(parents=True) + (files / "PG_VERSION").write_text("17", encoding="utf-8") + (generation / MANIFEST_FILE).write_text( + json.dumps( + { + "schema": MANIFEST_SCHEMA, + "layout": {"files_dir": FILES_DIR, "sql_dir": SQL_DIR}, + "volumes": { + volume: {"database": True, "dumped": False, "engine": engine} + }, + } + ), + encoding="utf-8", + ) + env = { + **os.environ, + "REPO_DIR": repo, + "NEWEST_GENERATION": GENERATION, + "BKP_TEST_REPO_ROOT": str(PROJECT_ROOT), + "BKP_TEST_PYTHON": sys.executable, + "BKP_TEST_CONSUMERLESS_DB_VOLUMES": consumerless, + } + return subprocess.run( + ["bash", str(SCRIPT)], capture_output=True, text=True, env=env, check=False + ) + + +class TestVerifyBackup(unittest.TestCase): + def assert_passes(self, result: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("nothing owes a dump", result.stdout) + + def assert_fails(self, result: subprocess.CompletedProcess[str]) -> None: + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("but no sql dump", result.stdout) + + def test_an_unconsumed_provider_owes_no_dump(self) -> None: + self.assert_passes(_verify("postgres_data", "postgres", "postgres_data")) + + def test_a_consumed_provider_owes_a_dump(self) -> None: + self.assert_fails(_verify("postgres_data", "postgres", "")) + + def test_only_the_named_volume_is_released(self) -> None: + self.assert_fails(_verify("mariadb_data", "mariadb", "postgres_data")) + + def test_a_name_is_matched_whole_not_as_a_prefix(self) -> None: + self.assert_fails(_verify("postgres_data", "postgres", "postgres")) + + +if __name__ == "__main__": + unittest.main()