mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
fix(utils/cache): allow utils.cache.data import without ansible
CI run 24934007615 broke every per-app deploy job (test-deploy-server,
test-deploy-universal — ~70 jobs) with the same trace:
cli/deploy/development/init.py:10
→ cli/deploy/development/inventory.py:26
→ utils/cache/data.py:15
→ plugins/lookup/application_gid.py:7
→ ansible.plugins.lookup
ModuleNotFoundError: No module named 'ansible'
`scripts/tests/deploy/ci/dedicated.sh:167` runs `python3 -m
cli.deploy.development init` directly on the GitHub Actions runner host
to materialise the dev inventory. The runner Python (3.12) ships without
`ansible`, but `utils.cache.data` was eager-importing
`ApplicationGidLookup` (extends `ansible.plugins.lookup.LookupBase`) and
`_templar_render_best_effort` (uses `ansible.errors.AnsibleError`) at
module scope. The cheap `get_variants` entry point — the only thing the
init step actually needs — therefore refused to import.
Move both ansible-coupled imports into the call sites that genuinely
need them:
- `_templar_render_best_effort` is now a lazy import inside
`_materialize_builtin_user_aliases` and `_render_with_templar` (the
two functions whose closures use it).
- `ApplicationGidLookup` is now a lazy import inside `_build_variants`
(the only caller that instantiates it). The forward reference in
`_build_role_base_config(gid_lookup: ApplicationGidLookup)` keeps
working because `from __future__ import annotations` is already in
effect; a `TYPE_CHECKING` import keeps static type checkers happy.
`merge_with_defaults` is pure-Python and stays at module scope.
Add `TestImportableWithoutAnsible` (two tests, one for `utils.cache.
data.get_variants`, one for the full
`cli.deploy.development.inventory.plan_dev_inventory_matrix` chain) that
artificially blocks `ansible` via a `MetaPathFinder` and asserts the
imports still complete. Without this guard a future eager import of an
ansible-coupled symbol would silently break ~70 CI jobs again.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2f5e2b4154
commit
8e4886d705
2 files changed
+107
-3
No files matched your search
Vendored
+80
@@ -507,5 +507,85 @@ class TestImplicitRolesDir(unittest.TestCase):
|
||||
self.assertIn("contact", defaults)
|
||||
|
||||
|
||||
class TestImportableWithoutAnsible(unittest.TestCase):
|
||||
"""Pin: `utils.cache.data` MUST be importable without `ansible` on
|
||||
sys.path.
|
||||
|
||||
Why pin: the `cli.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. CI run 24934007615 broke
|
||||
every per-app deploy job because `from utils.cache.data import
|
||||
get_variants` transitively pulled `from
|
||||
plugins.lookup.application_gid import LookupModule` and that pulled
|
||||
`from ansible.plugins.lookup import LookupBase`, raising
|
||||
`ModuleNotFoundError: No module named 'ansible'` before the actual
|
||||
deploy could even start. The fix moved the two ansible-coupled
|
||||
imports (`ApplicationGidLookup`, `_templar_render_best_effort`) out
|
||||
of the module-level import block into lazy imports at the call sites
|
||||
that genuinely need them. This test simulates the CI runner by
|
||||
blocking `ansible` via a `MetaPathFinder` and asserts that the cheap
|
||||
entry point still imports.
|
||||
"""
|
||||
|
||||
def _import_under_ansible_block(self, module_name: str, attr: str):
|
||||
"""Re-import `module_name` with `ansible` artificially blocked."""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
class _Block:
|
||||
def find_spec(self, name, path=None, target=None):
|
||||
if name == "ansible" or name.startswith("ansible."):
|
||||
raise ImportError(f"simulated: ansible blocked for {name}")
|
||||
return None
|
||||
|
||||
original_meta_path = list(sys.meta_path)
|
||||
snapshot = {
|
||||
k: sys.modules[k]
|
||||
for k in list(sys.modules)
|
||||
if k == "ansible"
|
||||
or k.startswith("ansible.")
|
||||
or k == module_name
|
||||
or k.startswith(module_name + ".")
|
||||
or k.startswith("plugins.lookup.application_gid")
|
||||
or k.startswith("utils.templating")
|
||||
}
|
||||
# Drop the cached modules so the next import goes through the block.
|
||||
for k in snapshot:
|
||||
sys.modules.pop(k, None)
|
||||
|
||||
sys.meta_path.insert(0, _Block())
|
||||
try:
|
||||
mod = importlib.import_module(module_name)
|
||||
return getattr(mod, attr)
|
||||
finally:
|
||||
sys.meta_path[:] = original_meta_path
|
||||
# Restore originals so the rest of the test session keeps working
|
||||
# against the real (ansible-aware) implementations.
|
||||
for k in list(sys.modules):
|
||||
if (
|
||||
k == module_name
|
||||
or k.startswith(module_name + ".")
|
||||
or k.startswith("plugins.lookup.application_gid")
|
||||
or k.startswith("utils.templating")
|
||||
):
|
||||
sys.modules.pop(k, None)
|
||||
for k, m in snapshot.items():
|
||||
sys.modules[k] = m
|
||||
|
||||
def test_get_variants_importable_without_ansible(self):
|
||||
get_variants_fn = self._import_under_ansible_block(
|
||||
"utils.cache.data", "get_variants"
|
||||
)
|
||||
self.assertTrue(callable(get_variants_fn))
|
||||
|
||||
def test_inventory_module_importable_without_ansible(self):
|
||||
# The actual call chain that broke in CI run 24934007615:
|
||||
# cli.deploy.development.init -> .inventory -> utils.cache.data
|
||||
plan_fn = self._import_under_ansible_block(
|
||||
"cli.deploy.development.inventory", "plan_dev_inventory_matrix"
|
||||
)
|
||||
self.assertTrue(callable(plan_fn))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Vendored
+27
-3
@@ -6,14 +6,25 @@ import os
|
||||
import threading
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Optional
|
||||
from typing import TYPE_CHECKING, Any, Mapping, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import yaml
|
||||
|
||||
# `merge_with_defaults` is a pure-Python helper with no `ansible` dependency,
|
||||
# so it stays at module scope.
|
||||
from plugins.filter.merge_with_defaults import merge_with_defaults
|
||||
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
|
||||
from utils.templating import _templar_render_best_effort
|
||||
|
||||
# `ApplicationGidLookup` and `_templar_render_best_effort` transitively pull
|
||||
# in `ansible`, but several callers of this module (notably the
|
||||
# `cli.deploy.development` CLI on the GitHub Actions runner host) only need
|
||||
# the cheap, ansible-free entry points such as `get_variants`. Importing
|
||||
# either of those at module scope would make `import utils.cache.data` raise
|
||||
# `ModuleNotFoundError: No module named 'ansible'` on hosts that ship the
|
||||
# CLI but no ansible interpreter (see CI run 24934007615 for the full
|
||||
# trace). Defer the imports to the call sites that actually need them.
|
||||
if TYPE_CHECKING:
|
||||
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
|
||||
|
||||
try:
|
||||
from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString
|
||||
@@ -434,6 +445,10 @@ def _materialize_builtin_user_aliases(
|
||||
variables: Optional[Mapping[str, Any]],
|
||||
templar: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
# Lazy import: pulls `ansible.errors.AnsibleError` transitively, see the
|
||||
# module-level note on why this stays out of the import block.
|
||||
from utils.templating import _templar_render_best_effort
|
||||
|
||||
def _normalize_domain_candidate(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
@@ -550,6 +565,10 @@ def _render_with_templar(
|
||||
if templar is None:
|
||||
return value
|
||||
|
||||
# Lazy import: pulls `ansible.errors.AnsibleError` transitively, see the
|
||||
# module-level note on why this stays out of the import block.
|
||||
from utils.templating import _templar_render_best_effort
|
||||
|
||||
# Start from whatever the templar already had available so that
|
||||
# ansible_facts/hostvars stay accessible during nested renders. Overlay the
|
||||
# caller-supplied variables on top, then inject our raw.*_RAW helpers.
|
||||
@@ -650,6 +669,11 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
|
||||
role keeps its pre-variant behaviour. The single-variant case is
|
||||
equivalent to the legacy `_build_application_defaults` output.
|
||||
"""
|
||||
# Lazy import: `ApplicationGidLookup` extends `ansible.plugins.lookup.
|
||||
# LookupBase`. See the module-level note on why this stays out of the
|
||||
# top-of-file import block.
|
||||
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
|
||||
|
||||
gid_lookup = ApplicationGidLookup()
|
||||
variants: dict[str, list[Any]] = {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user