mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
`3c1d5d821` solved the cross-round backbone-abriss problem by making `include` equal the union of every variant's closure for every round. Side-effect: every matrix round then ALSO deployed the full union, so `08_deploy_performance.sh` saw `dashboard.infinito.example` still answering HTTP 200 after a matomo-only `deploy-fresh-purged-apps` — the deploy step was no longer matomo-only at all. Split the two concerns instead: - `include` (PlanEntry[3]) goes back to the per-round variant-closure. Both `init`/`builder` (`--include` for the inventory bake) and `deploy` (`--id` for ansible) consume it. Each round therefore deploys ONLY its own variant-closure again — the pre-`3c1d5d821` semantics. - `purge_set` (PlanEntry[4], new) carries the union across all rounds. `deploy.py` uses it for the inter-round wipe instead of the previous round's include. Result: between rounds the WHOLE matrix footprint is purged, not just the previous round's variant slice — a variant pinning `services.<X>.shared: false` cannot leave its bundled provider on the host for a sibling round that wires the same service shared. The backbone-abriss `3c1d5d821` was hunting stays fixed: whichever round needs the backbone re-deploys it immediately after the wipe (purge runs at the start of each round, not the end). `previous_round_include` tracking is gone. The first iteration skips the purge (nothing to wipe yet), which also covers `total_rounds == 1` and `--variant N` pinned single-folder modes cleanly. Unrelated, but staged together because it is part of the same investigation: - `roles/dev-nix/templates/nix.conf.j2`: enable `keep-derivations` / `keep-outputs`, so `nix-collect-garbage -d` (run by the aggressive prune step) does not drop the build closure of pkgmgr's flake; this was making every deploy re-fetch the build inputs from cache.nixos.org and overshoot pkgmgr's update timeout. - `roles/pkgmgr/tasks/01_core.yml`: raise the pkgmgr update timeout from 600s to 1800s and lower the retry count from 6 to 3, so a slow fetch on a single try no longer pushes total wallclock past three hours. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
614 lines
23 KiB
Python
614 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import textwrap
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from utils.cache import _reset_cache_for_tests
|
|
from utils.cache.applications import get_application_defaults
|
|
from utils.cache.base import (
|
|
PROJECT_ROOT,
|
|
ROLES_DIR,
|
|
_deep_merge,
|
|
_fingerprint_mapping,
|
|
_stable_variables_signature,
|
|
)
|
|
from utils.cache.users import (
|
|
_build_users,
|
|
_compute_reserved_usernames,
|
|
_hydrate_users_tokens,
|
|
_load_user_defs,
|
|
_materialize_builtin_user_aliases,
|
|
_merge_users,
|
|
get_user_defaults,
|
|
)
|
|
from utils.roles.mapping import ROLE_FILE_META_SERVER, ROLE_FILE_META_USERS
|
|
|
|
|
|
def _write(path: Path, content: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(textwrap.dedent(content), encoding="utf-8")
|
|
|
|
|
|
class TestDeepMerge(unittest.TestCase):
|
|
def test_nested_merge(self):
|
|
base = {"a": {"b": 1, "c": 2}, "keep": [1]}
|
|
override = {"a": {"c": 99, "d": 3}, "new": "x"}
|
|
merged = _deep_merge(base, override)
|
|
self.assertEqual(
|
|
merged,
|
|
{"a": {"b": 1, "c": 99, "d": 3}, "keep": [1], "new": "x"},
|
|
)
|
|
|
|
def test_scalar_override_replaces_mapping(self):
|
|
self.assertEqual(_deep_merge({"a": 1}, "x"), "x")
|
|
|
|
def test_override_deepcopies(self):
|
|
base = {}
|
|
override = {"a": {"list": [1, 2]}}
|
|
merged = _deep_merge(base, override)
|
|
merged["a"]["list"].append(3)
|
|
self.assertEqual(override["a"]["list"], [1, 2])
|
|
|
|
|
|
class TestMergeUsers(unittest.TestCase):
|
|
def test_none_overrides_returns_defaults_copy(self):
|
|
defaults = {"alice": {"uid": 1001}}
|
|
merged = _merge_users(defaults, None)
|
|
self.assertEqual(merged, defaults)
|
|
merged["alice"]["uid"] = 0
|
|
self.assertEqual(defaults["alice"]["uid"], 1001)
|
|
|
|
def test_override_merges_per_user(self):
|
|
defaults = {"alice": {"uid": 1001, "email": "a@x"}}
|
|
overrides = {"alice": {"email": "new@x"}, "bob": {"uid": 2000}}
|
|
merged = _merge_users(defaults, overrides)
|
|
self.assertEqual(merged["alice"], {"uid": 1001, "email": "new@x"})
|
|
self.assertEqual(merged["bob"], {"uid": 2000})
|
|
|
|
|
|
class TestComputeReservedUsernames(unittest.TestCase):
|
|
def test_extracts_suffix_after_last_dash(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
(roles / "web-app-mailu").mkdir()
|
|
(roles / "svc-db-postgres").mkdir()
|
|
(roles / "sys-ctl-alm-email").mkdir()
|
|
(roles / "not-a-dir.txt").write_text("", encoding="utf-8")
|
|
|
|
self.assertEqual(
|
|
_compute_reserved_usernames(roles),
|
|
["email", "mailu", "postgres"],
|
|
)
|
|
|
|
def test_ignores_non_alnum_or_non_lower(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
(roles / "web-app-Foo").mkdir()
|
|
(roles / "weird-name_with_underscore").mkdir()
|
|
(roles / "svc-db-pg").mkdir()
|
|
|
|
self.assertEqual(_compute_reserved_usernames(roles), ["pg"])
|
|
|
|
|
|
class TestFingerprintMapping(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_none_is_zero(self):
|
|
self.assertEqual(_fingerprint_mapping(None), "0")
|
|
|
|
def test_same_content_same_fingerprint(self):
|
|
a = {"x": 1, "y": 2}
|
|
b = {"y": 2, "x": 1}
|
|
self.assertEqual(_fingerprint_mapping(a), _fingerprint_mapping(b))
|
|
|
|
def test_different_content_different_fingerprint(self):
|
|
a = {"x": 1}
|
|
b = {"x": 2}
|
|
self.assertNotEqual(_fingerprint_mapping(a), _fingerprint_mapping(b))
|
|
|
|
def test_id_cache_short_circuits_recompute(self):
|
|
obj = {"x": 1}
|
|
first = _fingerprint_mapping(obj)
|
|
obj["x"] = 999
|
|
cached = _fingerprint_mapping(obj)
|
|
self.assertEqual(first, cached)
|
|
_reset_cache_for_tests()
|
|
self.assertNotEqual(first, _fingerprint_mapping(obj))
|
|
|
|
|
|
class TestStableVariablesSignature(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_empty_variables_returns_sentinel(self):
|
|
self.assertEqual(_stable_variables_signature(None), ("0", "0", "", ""))
|
|
self.assertEqual(_stable_variables_signature({}), ("0", "0", "", ""))
|
|
|
|
def test_captures_key_subset(self):
|
|
sig = _stable_variables_signature(
|
|
{
|
|
"applications": {"a": 1},
|
|
"users": {"u": 2},
|
|
"DOMAIN_PRIMARY": "example.com",
|
|
"SYSTEM_EMAIL_DOMAIN": "mail.example.com",
|
|
"irrelevant": "noise",
|
|
}
|
|
)
|
|
self.assertEqual(sig[2], "example.com")
|
|
self.assertEqual(sig[3], "mail.example.com")
|
|
self.assertNotEqual(sig[0], "0")
|
|
self.assertNotEqual(sig[1], "0")
|
|
|
|
def test_signature_stable_across_identical_content(self):
|
|
v1 = {"applications": {"a": 1}, "users": {"u": 2}}
|
|
v2 = {"applications": {"a": 1}, "users": {"u": 2}}
|
|
self.assertEqual(
|
|
_stable_variables_signature(v1), _stable_variables_signature(v2)
|
|
)
|
|
|
|
|
|
class TestLoadUserDefs(unittest.TestCase):
|
|
def test_merges_non_conflicting_across_roles(self):
|
|
# Per the file root of meta/users.yml IS the users map
|
|
# (no `users:` wrapper).
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"role-a/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
alice:
|
|
username: alice
|
|
""",
|
|
)
|
|
_write(
|
|
roles / f"role-b/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
alice:
|
|
email: alice@x
|
|
bob:
|
|
username: bob
|
|
""",
|
|
)
|
|
defs = _load_user_defs(roles)
|
|
self.assertEqual(defs["alice"], {"username": "alice", "email": "alice@x"})
|
|
self.assertEqual(defs["bob"], {"username": "bob"})
|
|
|
|
def test_conflicting_field_raises(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"role-a/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
alice:
|
|
uid: 1001
|
|
""",
|
|
)
|
|
_write(
|
|
roles / f"role-b/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
alice:
|
|
uid: 2002
|
|
""",
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "Conflict for user 'alice'"):
|
|
_load_user_defs(roles)
|
|
|
|
def test_non_dict_user_entry_raises(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"role-a/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
alice: "not-a-dict"
|
|
""",
|
|
)
|
|
with self.assertRaisesRegex(TypeError, "Invalid definition"):
|
|
_load_user_defs(roles)
|
|
|
|
|
|
class TestBuildUsers(unittest.TestCase):
|
|
def _defs(self, mapping):
|
|
from collections import OrderedDict
|
|
|
|
return OrderedDict(mapping)
|
|
|
|
def test_allocates_uids_starting_at_start_id(self):
|
|
defs = self._defs([("alice", {}), ("bob", {})])
|
|
users = _build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
self.assertEqual(users["alice"]["uid"], 1001)
|
|
self.assertEqual(users["bob"]["uid"], 1002)
|
|
self.assertEqual(users["alice"]["gid"], 1001)
|
|
|
|
def test_explicit_uid_preserved_allocator_skips(self):
|
|
defs = self._defs(
|
|
[("alice", {"uid": 1001}), ("bob", {}), ("carol", {"uid": 1002})]
|
|
)
|
|
users = _build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
self.assertEqual(users["alice"]["uid"], 1001)
|
|
self.assertEqual(users["bob"]["uid"], 1003)
|
|
self.assertEqual(users["carol"]["uid"], 1002)
|
|
|
|
def test_duplicate_explicit_uid_raises(self):
|
|
defs = self._defs([("alice", {"uid": 1001}), ("bob", {"uid": 1001})])
|
|
with self.assertRaisesRegex(ValueError, "Duplicate uid 1001"):
|
|
_build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
|
|
def test_duplicate_username_raises(self):
|
|
defs = self._defs(
|
|
[("alice", {"username": "shared"}), ("bob", {"username": "shared"})]
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "Duplicate username 'shared'"):
|
|
_build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
|
|
def test_duplicate_email_raises(self):
|
|
defs = self._defs(
|
|
[
|
|
("alice", {"email": "same@x"}),
|
|
("bob", {"email": "same@x"}),
|
|
]
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "Duplicate email 'same@x'"):
|
|
_build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
|
|
def test_defaults_fill_missing_fields(self):
|
|
defs = self._defs([("alice", {})])
|
|
users = _build_users(defs, "example.com", start_id=1001, become_pwd="pw")
|
|
self.assertEqual(users["alice"]["username"], "alice")
|
|
self.assertEqual(users["alice"]["email"], "alice@example.com")
|
|
self.assertEqual(users["alice"]["password"], "pw")
|
|
self.assertEqual(users["alice"]["firstname"], "alice")
|
|
self.assertEqual(users["alice"]["lastname"], "example.com")
|
|
|
|
|
|
class TestHydrateUsersTokens(unittest.TestCase):
|
|
def test_fills_only_missing_tokens(self):
|
|
users = {
|
|
"alice": {"tokens": {"app-a": "keep", "app-b": ""}},
|
|
"bob": {"tokens": {}},
|
|
}
|
|
store = {
|
|
"alice": {"tokens": {"app-a": "overwrite-attempt", "app-b": "fill"}},
|
|
"bob": {"tokens": {"app-c": "fresh"}},
|
|
"carol": {"tokens": {"app-d": "orphan"}},
|
|
}
|
|
hydrated = _hydrate_users_tokens(users, store)
|
|
self.assertEqual(hydrated["alice"]["tokens"]["app-a"], "keep")
|
|
self.assertEqual(hydrated["alice"]["tokens"]["app-b"], "fill")
|
|
self.assertEqual(hydrated["bob"]["tokens"]["app-c"], "fresh")
|
|
self.assertIn("carol", hydrated)
|
|
self.assertEqual(hydrated["carol"]["tokens"]["app-d"], "orphan")
|
|
|
|
def test_empty_store_is_passthrough(self):
|
|
users = {"alice": {"tokens": {"a": "1"}}}
|
|
self.assertEqual(_hydrate_users_tokens(users, None), users)
|
|
self.assertEqual(_hydrate_users_tokens(users, {}), users)
|
|
|
|
def test_does_not_mutate_input(self):
|
|
users = {"alice": {"tokens": {}}}
|
|
store = {"alice": {"tokens": {"x": "y"}}}
|
|
_hydrate_users_tokens(users, store)
|
|
self.assertEqual(users["alice"]["tokens"], {})
|
|
|
|
|
|
class TestMaterializeBuiltinUserAliases(unittest.TestCase):
|
|
def test_rewrites_sld_and_tld_usernames(self):
|
|
users = {
|
|
"sld": {"username": "{{ DOMAIN_PRIMARY.split('.')[0] }}"},
|
|
"tld": {"username": "{{ DOMAIN_PRIMARY.split('.')[1] }}"},
|
|
}
|
|
result = _materialize_builtin_user_aliases(
|
|
users, {"DOMAIN_PRIMARY": "example.com"}
|
|
)
|
|
self.assertEqual(result["sld"]["username"], "example")
|
|
self.assertEqual(result["tld"]["username"], "com")
|
|
|
|
def test_no_domain_primary_is_passthrough(self):
|
|
users = {"sld": {"username": "{{ DOMAIN_PRIMARY.split('.')[0] }}"}}
|
|
result = _materialize_builtin_user_aliases(users, {})
|
|
self.assertEqual(result, users)
|
|
|
|
def test_non_placeholder_username_untouched(self):
|
|
users = {"sld": {"username": "literal"}}
|
|
result = _materialize_builtin_user_aliases(
|
|
users, {"DOMAIN_PRIMARY": "example.com"}
|
|
)
|
|
self.assertEqual(result["sld"]["username"], "literal")
|
|
|
|
|
|
class TestGetApplicationDefaults(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def tearDown(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_reads_role_config_files(self):
|
|
# Per the server topic now lives in its own
|
|
# `meta/server.yml`, not nested inside `meta/services.yml`.
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_SERVER}",
|
|
"""
|
|
domains:
|
|
canonical:
|
|
- alpha.example
|
|
""",
|
|
)
|
|
_write(
|
|
roles / f"web-app-beta/{ROLE_FILE_META_SERVER}",
|
|
"""
|
|
domains:
|
|
canonical:
|
|
- beta.example
|
|
""",
|
|
)
|
|
defaults = get_application_defaults(roles_dir=roles)
|
|
self.assertEqual(sorted(defaults.keys()), ["web-app-alpha", "web-app-beta"])
|
|
self.assertIn("group_id", defaults["web-app-alpha"])
|
|
self.assertEqual(
|
|
defaults["web-app-alpha"]["server"]["domains"]["canonical"],
|
|
["alpha.example"],
|
|
)
|
|
|
|
def test_wires_users_to_lookup_indirection(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_SERVER}",
|
|
"""
|
|
{}
|
|
""",
|
|
)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
administrator:
|
|
username: administrator
|
|
""",
|
|
)
|
|
defaults = get_application_defaults(roles_dir=roles)
|
|
self.assertEqual(
|
|
defaults["web-app-alpha"]["users"]["administrator"],
|
|
"{{ lookup('users', 'administrator') }}",
|
|
)
|
|
|
|
def test_cache_returns_deep_copy(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_SERVER}",
|
|
"""
|
|
domains:
|
|
canonical:
|
|
- alpha.example
|
|
""",
|
|
)
|
|
first = get_application_defaults(roles_dir=roles)
|
|
first["web-app-alpha"]["server"]["mutated"] = True
|
|
second = get_application_defaults(roles_dir=roles)
|
|
self.assertNotIn("mutated", second["web-app-alpha"]["server"])
|
|
|
|
|
|
class TestGetUserDefaults(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def tearDown(self) -> None:
|
|
_reset_cache_for_tests()
|
|
|
|
def test_includes_role_defined_users(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
administrator:
|
|
username: administrator
|
|
""",
|
|
)
|
|
users = get_user_defaults(roles_dir=roles)
|
|
self.assertIn("administrator", users)
|
|
self.assertEqual(users["administrator"]["uid"], 1001)
|
|
|
|
def test_adds_reserved_users_from_role_suffixes(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
(roles / "svc-db-postgres").mkdir()
|
|
(roles / "sys-ctl-alm-email").mkdir()
|
|
users = get_user_defaults(roles_dir=roles)
|
|
self.assertIn("postgres", users)
|
|
self.assertTrue(users["postgres"]["reserved"])
|
|
self.assertIn("email", users)
|
|
self.assertTrue(users["email"]["reserved"])
|
|
|
|
def test_cache_returns_deep_copy(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
roles = Path(tmp)
|
|
_write(
|
|
roles / f"web-app-alpha/{ROLE_FILE_META_USERS}",
|
|
"""
|
|
administrator:
|
|
username: administrator
|
|
""",
|
|
)
|
|
first = get_user_defaults(roles_dir=roles)
|
|
first["administrator"]["mutated"] = True
|
|
second = get_user_defaults(roles_dir=roles)
|
|
self.assertNotIn("mutated", second["administrator"])
|
|
|
|
|
|
class TestImplicitRolesDir(unittest.TestCase):
|
|
"""Pin the implicit ROLES_DIR to the actual repo `roles/` directory.
|
|
|
|
Why pin: when this module was at `utils/runtime_data.py`,
|
|
`parents[1]` resolved to the repo root. After the move to
|
|
`utils/cache/base.py` (and previously `utils/cache/data.py`),
|
|
`parents[1]` silently became `utils/`, which is itself a python
|
|
package. Glob walks like
|
|
`<utils>/roles/*/meta/users.yml` then yielded zero matches and
|
|
`lookup('users', 'contact')` started failing during deploy as if
|
|
`contact` were undefined. Encode the invariant explicitly so any
|
|
future move that changes the depth is caught here, not by a
|
|
multi-hour deploy that fails inside Ansible.
|
|
"""
|
|
|
|
def test_project_root_is_repo_root(self):
|
|
# The repo root has both `roles/` and `cli/` at the top level;
|
|
# `utils/` does not. Asserting both keeps the test robust against
|
|
# accidental drift to either direction.
|
|
self.assertTrue(
|
|
(PROJECT_ROOT / "roles").is_dir(),
|
|
f"expected <repo>/roles under PROJECT_ROOT={PROJECT_ROOT}",
|
|
)
|
|
self.assertTrue(
|
|
(PROJECT_ROOT / "cli").is_dir(),
|
|
f"expected <repo>/cli under PROJECT_ROOT={PROJECT_ROOT}",
|
|
)
|
|
self.assertEqual(ROLES_DIR, PROJECT_ROOT / "roles")
|
|
|
|
def test_implicit_user_defaults_include_role_defined_users(self):
|
|
# `contact` is contributed by web-app-odoo and web-app-espocrm
|
|
# role users files. If ROLES_DIR is wrong, the implicit lookup
|
|
# silently returns an empty defaults map and this regresses.
|
|
defaults = get_user_defaults()
|
|
self.assertIn("contact", defaults)
|
|
|
|
|
|
def _run_in_ansible_blocked_subprocess(snippet: str):
|
|
"""Spawn a fresh `python3` subprocess that installs a meta-path
|
|
finder denying every `ansible*` import, then runs the given
|
|
snippet against the real repo. Returns ``(returncode, stdout,
|
|
stderr)``.
|
|
|
|
Subprocess isolation avoids the in-process module-state hazards
|
|
(namespace packages, cross-test sys.modules churn, identity
|
|
fragmentation of cache dicts) that plagued earlier in-process
|
|
`_AnsibleBlock` shims and broke under Python 3.13 inside the
|
|
infinito container. Each subprocess starts with a clean import
|
|
state and a clean `sys.meta_path`.
|
|
"""
|
|
import subprocess
|
|
import sys
|
|
|
|
repo_root = PROJECT_ROOT
|
|
preamble = (
|
|
"import sys\n"
|
|
f"sys.path.insert(0, {str(repo_root)!r})\n"
|
|
"class _Block:\n"
|
|
" def find_spec(self, name, path=None, target=None):\n"
|
|
" if name == 'ansible' or name.startswith('ansible.'):\n"
|
|
" raise ImportError(f'blocked: {name}')\n"
|
|
" return None\n"
|
|
"sys.meta_path.insert(0, _Block())\n"
|
|
)
|
|
code = preamble + snippet
|
|
result = subprocess.run(
|
|
[sys.executable, "-c", code],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=str(repo_root),
|
|
timeout=60,
|
|
check=False,
|
|
)
|
|
return result.returncode, result.stdout, result.stderr
|
|
|
|
|
|
class TestImportableWithoutAnsible(unittest.TestCase):
|
|
"""Pin: `utils.cache.applications` MUST be importable AND its
|
|
hot-path callables MUST run without `ansible` on sys.path.
|
|
|
|
Why pin: the `cli.administration.deploy.development` CLI runs on the GitHub Actions
|
|
runner host (see scripts/tests/deploy/ci/dedicated.sh) where the
|
|
runner Python has no `ansible` package. The original CI failure
|
|
(run 24934007615) was at *import* time. The follow-up CI failure
|
|
(run 24935979190) showed that import-only guards are not enough:
|
|
`_build_variants` still instantiated `ApplicationGidLookup` at
|
|
*call* time, which transitively imported
|
|
`ansible.plugins.lookup.LookupBase` the moment the function was
|
|
invoked. The fix split `compute_application_gid` (pure-Python) from
|
|
the ansible-facing `LookupModule` wrapper. These tests pin BOTH
|
|
layers — import-time AND call-time — by spawning a fresh subprocess
|
|
so the regression is caught at unit-test time, not by a multi-hour
|
|
CI matrix.
|
|
"""
|
|
|
|
def test_get_variants_importable_without_ansible(self):
|
|
rc, out, err = _run_in_ansible_blocked_subprocess(
|
|
"from utils.cache.applications import get_variants\n"
|
|
"assert callable(get_variants)\n"
|
|
"print('OK')\n"
|
|
)
|
|
self.assertEqual(rc, 0, msg=f"stderr=\n{err}\nstdout=\n{out}")
|
|
self.assertIn("OK", out)
|
|
|
|
def test_inventory_module_importable_without_ansible(self):
|
|
# The actual call chain that broke in CI run 24934007615:
|
|
# cli.administration.deploy.development.init -> .inventory ->
|
|
# utils.cache.applications
|
|
rc, out, err = _run_in_ansible_blocked_subprocess(
|
|
"from cli.administration.deploy.development.inventory import "
|
|
"plan_dev_inventory_matrix\n"
|
|
"assert callable(plan_dev_inventory_matrix)\n"
|
|
"print('OK')\n"
|
|
)
|
|
self.assertEqual(rc, 0, msg=f"stderr=\n{err}\nstdout=\n{out}")
|
|
self.assertIn("OK", out)
|
|
|
|
def test_get_variants_callable_without_ansible(self):
|
|
# Pins CI run 24935979190 specifically: the import-time guards
|
|
# added in 8e4886d70 passed this very file's import-only tests
|
|
# but `get_variants(roles_dir=...)` still triggered the lazy
|
|
# import of `ApplicationGidLookup` and died at call time. This
|
|
# test invokes the function AGAINST the real repo roles dir
|
|
# while ansible is blocked.
|
|
rc, out, err = _run_in_ansible_blocked_subprocess(
|
|
"from utils.cache.applications import get_variants\n"
|
|
"from utils.cache.base import ROLES_DIR\n"
|
|
"v = get_variants(roles_dir=ROLES_DIR)\n"
|
|
"assert isinstance(v, dict)\n"
|
|
"assert len(v) > 0, 'expected at least one role'\n"
|
|
"sample_app, sample_variants = next(iter(v.items()))\n"
|
|
"assert isinstance(sample_variants, list)\n"
|
|
"assert len(sample_variants) > 0\n"
|
|
"print('OK', len(v))\n"
|
|
)
|
|
self.assertEqual(rc, 0, msg=f"stderr=\n{err}\nstdout=\n{out}")
|
|
self.assertIn("OK", out)
|
|
|
|
def test_plan_dev_inventory_matrix_callable_without_ansible(self):
|
|
# Same exhaustive shape as above, one level higher: the actual
|
|
# CLI path is `cli.administration.deploy.development.init.handler` ->
|
|
# `plan_dev_inventory_matrix(...)` -> `get_variants(...)`. We
|
|
# invoke the planner so a future regression at any layer of
|
|
# this chain trips here.
|
|
rc, out, err = _run_in_ansible_blocked_subprocess(
|
|
"from cli.administration.deploy.development.inventory import "
|
|
"plan_dev_inventory_matrix\n"
|
|
"from utils.cache.applications import get_variants\n"
|
|
"from utils.cache.base import ROLES_DIR\n"
|
|
"apps = list(get_variants(roles_dir=ROLES_DIR).keys())\n"
|
|
"sample = [apps[0]]\n"
|
|
"plan = plan_dev_inventory_matrix(\n"
|
|
" roles_dir=str(ROLES_DIR),\n"
|
|
" primary_apps=sample,\n"
|
|
" base_inventory_dir='/tmp/_inv_unused',\n"
|
|
")\n"
|
|
"assert len(plan) > 0\n"
|
|
"round_index, inv_dir, round_variants, include_R, purge_set_R = plan[0]\n"
|
|
"assert round_index == 0\n"
|
|
"assert sample[0] in round_variants\n"
|
|
"assert sample[0] in include_R\n"
|
|
"assert sample[0] in purge_set_R\n"
|
|
"print('OK')\n"
|
|
)
|
|
self.assertEqual(rc, 0, msg=f"stderr=\n{err}\nstdout=\n{out}")
|
|
self.assertIn("OK", out)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|