mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
A service flag may be written as `{{ '<role>' in group_names }}`; 114
meta/services.yml files do exactly that, and it is the only host-dependent
variable any of them uses. The rendered payload therefore differs per host
while the cache key did not: it was built from applications, users,
DOMAIN_PRIMARY and SYSTEM_EMAIL_DOMAIN alone, and in the failing job all
five host_vars files were byte-identical.
113113e553 moved the render out of every task on the premise that Ansible
forks a fresh worker per task, so the in-process cache is always cold. That
holds for workers and not for the strategy's main process, which renders a
task's name once for the first host of a batch. Anything cached there is
inherited by every worker forked afterwards, and applications.py consults
that process cache before the host's own carrier fact, so an inherited
entry outranks the correct answer.
Pretix#2 of run 35152776835 shows both sides in one task: `skipping:
[web-app-pretix-swarm-mgr-01]` against `included: dedicated.yml for
wrk-01, wrk-02`, eleven seconds before mgr-01 resolved its database to
`pretix_database`. The swarm was running the central `postgres_postgres`,
so the wait never converged and the job died after 600 attempts. Variants 0
and 1 pin the flag to a literal and both pass; only variant 2 inherits the
Jinja.
The groups join the key as a sorted comma-joined string rather than a
tuple, because carrier.py stringifies only one side of its key comparison.
A non-scalar component would make the carrier silently stop matching and
every lookup re-render.
domains derives from the applications view and inherits the same
dependency, so this closes a second instance that had not surfaced yet.
users reads group_names nowhere. No extra rendering results: group_names is
constant inside a worker, and the one process that sees several hosts is
the one the split is for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
595 lines
22 KiB
Python
595 lines
22 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_in_place_mutation_changes_the_fingerprint(self):
|
|
obj = {"x": 1}
|
|
first = _fingerprint_mapping(obj)
|
|
obj["x"] = 999
|
|
self.assertNotEqual(first, _fingerprint_mapping(obj))
|
|
|
|
def test_recycled_address_does_not_reuse_a_digest(self):
|
|
digests = []
|
|
for i in range(200):
|
|
transient = {"x": i}
|
|
digests.append(_fingerprint_mapping(transient))
|
|
del transient
|
|
self.assertEqual(len(set(digests)), 200)
|
|
|
|
|
|
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):
|
|
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):
|
|
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.assertEqual(users["postgres"]["accounts"], [])
|
|
self.assertIn("email", users)
|
|
self.assertEqual(users["email"]["accounts"], [])
|
|
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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):
|
|
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()
|