mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-26 21:45:20 +02:00
- 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
330 lines
9.2 KiB
Python
330 lines
9.2 KiB
Python
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()
|