mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-29 15:06:26 +02:00
Refactor and extend role dependency resolution:
- Introduced module_utils/role_dependency_resolver.py with full support for include_role, import_role, meta dependencies, and run_after. - Refactored cli/build/tree.py to use RoleDependencyResolver (added toggles for include/import/dependencies/run_after). - Extended filter_plugins/canonical_domains_map.py with optional 'recursive' mode (ignores run_after by design). - Updated roles/web-app-nextcloud to properly include Collabora dependency. - Added comprehensive unittests under tests/unit/module_utils for RoleDependencyResolver. Ref: https://chatgpt.com/share/68a519c8-8e54-800f-83c0-be38546620d9
This commit is contained in:
329
tests/unit/module_utils/test_role_dependency_resolver.py
Normal file
329
tests/unit/module_utils/test_role_dependency_resolver.py
Normal file
@@ -0,0 +1,329 @@
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from textwrap import dedent
|
||||
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
if PROJECT_ROOT not in sys.path:
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
from module_utils.role_dependency_resolver import RoleDependencyResolver # noqa: E402
|
||||
|
||||
|
||||
def write(path: str, content: str):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(dedent(content).lstrip())
|
||||
|
||||
|
||||
def make_role(roles_dir: str, name: str):
|
||||
path = os.path.join(roles_dir, name)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
os.makedirs(os.path.join(path, "tasks"), exist_ok=True)
|
||||
os.makedirs(os.path.join(path, "meta"), exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
class TestRoleDependencyResolver(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.roles_dir = tempfile.mkdtemp(prefix="roles_")
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.roles_dir, ignore_errors=True)
|
||||
|
||||
# ----------------------------- TESTS -----------------------------
|
||||
|
||||
def test_include_and_import_literal(self):
|
||||
"""
|
||||
A/tasks/main.yml:
|
||||
- include_role: { name: B }
|
||||
- import_role: { name: C }
|
||||
Expect: deps = {B, C}
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
make_role(self.roles_dir, "B")
|
||||
make_role(self.roles_dir, "C")
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "main.yml"),
|
||||
"""
|
||||
- name: include B
|
||||
include_role:
|
||||
name: B
|
||||
|
||||
- name: import C
|
||||
import_role:
|
||||
name: C
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
deps = r.get_role_dependencies("A")
|
||||
self.assertEqual(deps, {"B", "C"})
|
||||
|
||||
def test_loop_with_string_items_and_dict_items(self):
|
||||
"""
|
||||
A/tasks/main.yml uses loop with strings and dicts.
|
||||
Expect: {D, E, F, G}
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
for rn in ["D", "E", "F", "G"]:
|
||||
make_role(self.roles_dir, rn)
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "main.yml"),
|
||||
"""
|
||||
- name: loop over strings → D, E
|
||||
include_role:
|
||||
name: "{{ item }}"
|
||||
loop:
|
||||
- D
|
||||
- E
|
||||
|
||||
- name: loop over dicts → F, G
|
||||
import_role:
|
||||
name: "{{ item.role }}"
|
||||
with_items:
|
||||
- { role: "F" }
|
||||
- { role: "G" }
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
deps = r.get_role_dependencies("A")
|
||||
self.assertEqual(deps, {"D", "E", "F", "G"})
|
||||
|
||||
def test_jinja_mixed_name_glob_matching(self):
|
||||
"""
|
||||
include_role:
|
||||
name: "prefix-{{ item }}-suffix"
|
||||
loop: [x, y]
|
||||
Existierende Rollen: prefix-x-suffix, prefix-y-suffix, prefix-z-suffix
|
||||
|
||||
Erwartung:
|
||||
- KEINE Roh-Items ('x', 'y') als Rollen
|
||||
- Glob-Matching liefert die drei konkreten Rollen
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
for rn in ["prefix-x-suffix", "prefix-y-suffix", "prefix-z-suffix"]:
|
||||
make_role(self.roles_dir, rn)
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "main.yml"),
|
||||
"""
|
||||
- name: jinja-mixed glob
|
||||
include_role:
|
||||
name: "prefix-{{ item }}-suffix"
|
||||
loop:
|
||||
- x
|
||||
- y
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
deps = r.get_role_dependencies("A")
|
||||
|
||||
# keine Roh-Loop-Items
|
||||
self.assertNotIn("x", deps)
|
||||
self.assertNotIn("y", deps)
|
||||
|
||||
# erwartete Rollen aus dem Glob-Matching
|
||||
self.assertEqual(
|
||||
deps,
|
||||
{"prefix-x-suffix", "prefix-y-suffix", "prefix-z-suffix"},
|
||||
)
|
||||
|
||||
def test_pure_jinja_ignored_without_loop(self):
|
||||
"""
|
||||
name: "{{ something }}" with no loop should be ignored.
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
for rn in ["X", "Y"]:
|
||||
make_role(self.roles_dir, rn)
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "main.yml"),
|
||||
"""
|
||||
- name: pure var ignored
|
||||
include_role:
|
||||
name: "{{ something }}"
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
deps = r.get_role_dependencies("A")
|
||||
self.assertEqual(deps, set())
|
||||
|
||||
def test_meta_dependencies_strings_and_dicts(self):
|
||||
"""
|
||||
meta/main.yml:
|
||||
dependencies:
|
||||
- H
|
||||
- { role: I }
|
||||
Expect: {H, I}
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
make_role(self.roles_dir, "H")
|
||||
make_role(self.roles_dir, "I")
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "meta", "main.yml"),
|
||||
"""
|
||||
---
|
||||
dependencies:
|
||||
- H
|
||||
- { role: I }
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
deps = r.get_role_dependencies("A")
|
||||
self.assertEqual(deps, {"H", "I"})
|
||||
|
||||
def test_run_after_extraction_and_toggle(self):
|
||||
"""
|
||||
galaxy_info.run_after is only included when resolve_run_after=True
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
make_role(self.roles_dir, "J")
|
||||
make_role(self.roles_dir, "K")
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "meta", "main.yml"),
|
||||
"""
|
||||
---
|
||||
galaxy_info:
|
||||
run_after:
|
||||
- J
|
||||
- K
|
||||
dependencies: []
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
|
||||
# Direkter Helper
|
||||
ra = r._extract_meta_run_after(os.path.join(self.roles_dir, "A"))
|
||||
self.assertEqual(ra, {"J", "K"})
|
||||
|
||||
# Transitiv – off by default
|
||||
visited_off = r.resolve_transitively(["A"], resolve_run_after=False)
|
||||
self.assertNotIn("J", visited_off)
|
||||
self.assertNotIn("K", visited_off)
|
||||
|
||||
# Transitiv – enabled
|
||||
visited_on = r.resolve_transitively(["A"], resolve_run_after=True)
|
||||
self.assertTrue({"A", "J", "K"}.issubset(visited_on))
|
||||
|
||||
def test_cycle_and_max_depth(self):
|
||||
"""
|
||||
A → include B
|
||||
B → import A
|
||||
- Ensure cycle-safe traversal.
|
||||
- max_depth=0 → only start
|
||||
- max_depth=1 → start + direct deps
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
make_role(self.roles_dir, "B")
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "main.yml"),
|
||||
"""
|
||||
- include_role:
|
||||
name: B
|
||||
"""
|
||||
)
|
||||
write(
|
||||
os.path.join(self.roles_dir, "B", "tasks", "main.yml"),
|
||||
"""
|
||||
- import_role:
|
||||
name: A
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
|
||||
visited = r.resolve_transitively(["A"])
|
||||
self.assertTrue({"A", "B"}.issubset(visited))
|
||||
|
||||
only_start = r.resolve_transitively(["A"], max_depth=0)
|
||||
self.assertEqual(only_start, {"A"})
|
||||
|
||||
depth_one = r.resolve_transitively(["A"], max_depth=1)
|
||||
self.assertEqual(depth_one, {"A", "B"})
|
||||
|
||||
def test_tolerant_scan_fallback_on_invalid_yaml(self):
|
||||
"""
|
||||
Force yaml.safe_load_all to fail and ensure tolerant scan picks up:
|
||||
- include_role literal name
|
||||
- loop list items
|
||||
"""
|
||||
make_role(self.roles_dir, "A")
|
||||
for rn in ["R1", "R2", "R3"]:
|
||||
make_role(self.roles_dir, rn)
|
||||
|
||||
# Invalid YAML (e.g., stray colon) to trigger exception
|
||||
write(
|
||||
os.path.join(self.roles_dir, "A", "tasks", "broken.yml"),
|
||||
"""
|
||||
include_role:
|
||||
name: R1
|
||||
:: this line breaks YAML ::
|
||||
|
||||
- include_role:
|
||||
name: "{{ item }}"
|
||||
loop:
|
||||
- R2
|
||||
- R3
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
inc, imp = r._scan_tasks(os.path.join(self.roles_dir, "A"))
|
||||
self.assertTrue({"R1", "R2", "R3"}.issubset(inc))
|
||||
self.assertEqual(imp, set())
|
||||
|
||||
def test_resolve_transitively_combined_sources(self):
|
||||
"""
|
||||
Combined test: include/import + dependencies (+ optional run_after).
|
||||
"""
|
||||
for rn in ["ROOT", "C1", "C2", "D1", "D2", "RA1"]:
|
||||
make_role(self.roles_dir, rn)
|
||||
|
||||
write(
|
||||
os.path.join(self.roles_dir, "ROOT", "tasks", "main.yml"),
|
||||
"""
|
||||
- include_role: { name: C1 }
|
||||
- import_role: { name: C2 }
|
||||
"""
|
||||
)
|
||||
write(
|
||||
os.path.join(self.roles_dir, "ROOT", "meta", "main.yml"),
|
||||
"""
|
||||
---
|
||||
dependencies:
|
||||
- D1
|
||||
- { role: D2 }
|
||||
galaxy_info:
|
||||
run_after:
|
||||
- RA1
|
||||
"""
|
||||
)
|
||||
|
||||
r = RoleDependencyResolver(self.roles_dir)
|
||||
|
||||
# Ohne run_after
|
||||
visited = r.resolve_transitively(["ROOT"], resolve_run_after=False)
|
||||
for expected in ["ROOT", "C1", "C2", "D1", "D2"]:
|
||||
self.assertIn(expected, visited)
|
||||
self.assertNotIn("RA1", visited)
|
||||
|
||||
# Mit run_after
|
||||
visited_ra = r.resolve_transitively(["ROOT"], resolve_run_after=True)
|
||||
self.assertIn("RA1", visited_ra)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
Reference in New Issue
Block a user