mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 19:56:53 +00:00
`roles/user/meta/users.yml` reserved two names against the identity directory by
indexing the primary domain from the left: `sld` took label 0 and `tld` took
label 1. That is only the SLD and the TLD when the domain has exactly two
labels. On `label-a.tld.test` it reserved `label-a` and `tld` and let the real
TLD through, and on any deeper domain it drifts further.
The count is a runtime fact, so a static YAML file cannot express it. The two
entries are gone and `utils/cache/users` now emits one `domain_label_<n>` per
label:
infinito.nexus -> infinito, nexus (unchanged)
main.infinito.test -> main, infinito, test (was: main, infinito)
label-a.tld.test -> label-a, tld, test (was: label-a, tld)
Nothing read the `sld` or `tld` keys: a grep over roles, plugins, utils and cli
finds only the generator itself. The entries exist to carry `accounts: []` into
`reserved_usernames`, which `web-app-keycloak/tasks/02_init.yml:6` joins into the
username deny pattern, so the shape of the keys is free and only the set of
usernames matters.
Worth stating so the next reader does not overestimate it: the pattern at
`UserProfileProvider.json.j2:31` allows `[a-z0-9]+`, so a label carrying a hyphen
could never have been registered anyway. What this actually wins is the third
label. What it mainly wins is that the rule no longer has to be revisited when
the domain depth changes.
The `KEYCLOAK_DOMAIN` and `domain` fallbacks go with it. Both ran through a
helper that recovered the primary domain by keeping the last two labels of a
host like `auth.<DOMAIN_PRIMARY>`, which cannot work without already knowing the
depth it is trying to find. `DOMAIN_PRIMARY` and `SYSTEM_EMAIL_DOMAIN` remain,
and every deploy path supplies one of them. The test that pinned the removed
fallback is inverted rather than deleted, so the removal stays visible.
The rest is the refactor pass the change pulls in. The function no longer
materialises aliases and is renamed to `_materialize_domain_label_reservations`;
its candidate tuple carried one extractor after the fallbacks left and is a plain
key loop now; an `or [primary_domain]` guard that would have reserved a nonsense
username for an all-dots domain is dropped. `roles/user/vars/main.yml` loses four
comments that restated the line below them, and the README documents the
reservation registry, which no longer shows its full content on disk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
602 lines
22 KiB
Python
602 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_domain_label_reservations,
|
|
_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_reserves_one_username_per_domain_label(self):
|
|
result = _materialize_domain_label_reservations(
|
|
{}, {"DOMAIN_PRIMARY": "example.com"}
|
|
)
|
|
self.assertEqual(
|
|
[result[key]["username"] for key in sorted(result)],
|
|
["example", "com"],
|
|
)
|
|
|
|
def test_reserves_every_label_of_a_deep_primary_domain(self):
|
|
result = _materialize_domain_label_reservations(
|
|
{}, {"DOMAIN_PRIMARY": "label-a.tld.test"}
|
|
)
|
|
self.assertEqual(
|
|
[result[key]["username"] for key in sorted(result)],
|
|
["label-a", "tld", "test"],
|
|
)
|
|
|
|
def test_no_domain_primary_is_passthrough(self):
|
|
users = {"alice": {"username": "alice"}}
|
|
result = _materialize_domain_label_reservations(users, {})
|
|
self.assertEqual(result, users)
|
|
|
|
def test_non_placeholder_username_untouched(self):
|
|
users = {"sld": {"username": "literal"}}
|
|
result = _materialize_domain_label_reservations(
|
|
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()
|