Enhance tree builder: detect include_role dependencies from tasks/*.yml

- Added logic to scan each role’s tasks/*.yml files for include_role usage
- Supports:
  * loop/with_items with literal strings → adds each role
  * patterns with variables inside literals (e.g. svc-db-{{database_type}}) → expanded to glob and matched
  * pure variable-only names ({{var}}) → ignored
  * pure literal names → added directly
- Merges discovered dependencies under graphs["dependencies"]["include_role"]
- Added dedicated unit test covering looped includes, glob patterns, pure literals, and ignoring pure variables

See ChatGPT conversation (https://chatgpt.com/share/68a4ace0-7268-800f-bd32-b475c5c9ba1d) for context.
This commit is contained in:
Kevin Veen-Birkenbach 2025-08-19 18:57:56 +02:00
parent 82cc24a7f5
commit 6e538eabc8
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
3 changed files with 284 additions and 19 deletions

View File

@ -2,8 +2,12 @@
import os import os
import argparse import argparse
import json import json
import fnmatch
import re
from typing import Dict, Any from typing import Dict, Any
import yaml
from cli.build.graph import build_mappings, output_graph from cli.build.graph import build_mappings, output_graph
@ -15,45 +19,155 @@ def find_roles(roles_dir: str):
yield entry, path yield entry, path
def _is_pure_jinja_var(s: str) -> bool:
"""Check if string is exactly a single {{ var }} expression."""
return bool(re.fullmatch(r"\s*\{\{\s*[^}]+\s*\}\}\s*", s))
def _jinja_to_glob(s: str) -> str:
"""Convert Jinja placeholders {{ ... }} into * for fnmatch."""
pattern = re.sub(r"\{\{[^}]+\}\}", "*", s)
pattern = re.sub(r"\*{2,}", "*", pattern)
return pattern.strip()
def _list_role_dirs(roles_dir: str) -> list[str]:
"""Return a list of role directory names inside roles_dir."""
return [
d for d in os.listdir(roles_dir)
if os.path.isdir(os.path.join(roles_dir, d))
]
def find_include_role_dependencies(role_path: str, roles_dir: str) -> set[str]:
"""
Scan all tasks/*.yml(.yaml) files of a role and collect include_role dependencies.
Rules:
- loop/with_items with literal strings -> add those as roles
- name contains jinja AND surrounding literals -> convert to glob and match existing roles
- name is a pure jinja variable only -> ignore
- name is a pure literal -> add as-is
"""
deps: set[str] = set()
tasks_dir = os.path.join(role_path, "tasks")
if not os.path.isdir(tasks_dir):
return deps
candidates = []
for root, _, files in os.walk(tasks_dir):
for f in files:
if f.endswith(".yml") or f.endswith(".yaml"):
candidates.append(os.path.join(root, f))
all_roles = _list_role_dirs(roles_dir)
def add_literal_loop_items(loop_val):
if isinstance(loop_val, list):
for item in loop_val:
if isinstance(item, str) and item.strip():
deps.add(item.strip())
for file_path in candidates:
try:
with open(file_path, "r", encoding="utf-8") as f:
docs = list(yaml.safe_load_all(f))
except Exception:
# Be tolerant to any parsing issues; skip unreadable files
continue
for doc in docs:
if not isinstance(doc, list):
continue
for task in doc:
if not isinstance(task, dict):
continue
if "include_role" not in task:
continue
inc = task.get("include_role")
if not isinstance(inc, dict):
continue
name = inc.get("name")
if not isinstance(name, str) or not name.strip():
continue
# 1) Handle loop/with_items
loop_val = task.get("loop", task.get("with_items"))
if loop_val is not None:
add_literal_loop_items(loop_val)
# still check name for surrounding literals
if not _is_pure_jinja_var(name):
pattern = (
_jinja_to_glob(name)
if ("{{" in name and "}}" in name)
else name
)
if "*" in pattern:
for r in all_roles:
if fnmatch.fnmatch(r, pattern):
deps.add(r)
continue
# 2) No loop: evaluate name
if "{{" in name and "}}" in name:
if _is_pure_jinja_var(name):
continue # ignore pure variable
pattern = _jinja_to_glob(name)
if "*" in pattern:
for r in all_roles:
if fnmatch.fnmatch(r, pattern):
deps.add(r)
continue
else:
deps.add(pattern)
else:
# pure literal
deps.add(name.strip())
return deps
def main(): def main():
# default roles dir is ../../roles relative to this script # default roles dir is ../../roles relative to this script
script_dir = os.path.dirname(os.path.abspath(__file__)) script_dir = os.path.dirname(os.path.abspath(__file__))
default_roles_dir = os.path.abspath(os.path.join(script_dir, '..', '..', 'roles')) default_roles_dir = os.path.abspath(
os.path.join(script_dir, "..", "..", "roles")
)
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Generate all graphs for each role and write meta/tree.json" description="Generate all graphs for each role and write meta/tree.json"
) )
parser.add_argument( parser.add_argument(
'-d', '--role_dir', "-d", "--role_dir",
default=default_roles_dir, default=default_roles_dir,
help=f"Path to roles directory (default: {default_roles_dir})" help=f"Path to roles directory (default: {default_roles_dir})"
) )
parser.add_argument( parser.add_argument(
'-D', '--depth', "-D", "--depth",
type=int, type=int,
default=0, default=0,
help="Max recursion depth (>0) or <=0 to stop on cycle" help="Max recursion depth (>0) or <=0 to stop on cycle"
) )
parser.add_argument( parser.add_argument(
'-o', '--output', "-o", "--output",
choices=['yaml', 'json', 'console'], choices=["yaml", "json", "console"],
default='json', default="json",
help="Output format" help="Output format"
) )
parser.add_argument( parser.add_argument(
'-p', '--preview', "-p", "--preview",
action='store_true', action="store_true",
help="Preview graphs to console instead of writing files" help="Preview graphs to console instead of writing files"
) )
parser.add_argument( parser.add_argument(
'-s', '--shadow-folder', "-s", "--shadow-folder",
type=str, type=str,
default=None, default=None,
help="If set, writes tree.json to this shadow folder instead of the role's actual meta/ folder" help="If set, writes tree.json to this shadow folder instead of the role's actual meta/ folder"
) )
parser.add_argument( parser.add_argument(
'-v', '--verbose', "-v", "--verbose",
action='store_true', action="store_true",
help="Enable verbose logging" help="Enable verbose logging"
) )
args = parser.parse_args() args = parser.parse_args()
@ -75,24 +189,32 @@ def main():
max_depth=args.depth max_depth=args.depth
) )
# add include_role dependencies from tasks
include_deps = find_include_role_dependencies(role_path, args.role_dir)
if include_deps:
deps_root = graphs.setdefault("dependencies", {})
inc_list = set(deps_root.get("include_role", []))
inc_list.update(include_deps)
deps_root["include_role"] = sorted(inc_list)
graphs["dependencies"] = deps_root
if args.preview: if args.preview:
for key, data in graphs.items(): for key, data in graphs.items():
if args.verbose: if args.verbose:
print(f"Previewing graph '{key}' for role '{role_name}'") print(f"Previewing graph '{key}' for role '{role_name}'")
output_graph(data, 'console', role_name, key) output_graph(data, "console", role_name, key)
else: else:
# Decide on output folder
if args.shadow_folder: if args.shadow_folder:
tree_file = os.path.join( tree_file = os.path.join(
args.shadow_folder, role_name, 'meta', 'tree.json' args.shadow_folder, role_name, "meta", "tree.json"
) )
else: else:
tree_file = os.path.join(role_path, 'meta', 'tree.json') tree_file = os.path.join(role_path, "meta", "tree.json")
os.makedirs(os.path.dirname(tree_file), exist_ok=True) os.makedirs(os.path.dirname(tree_file), exist_ok=True)
with open(tree_file, 'w') as f: with open(tree_file, "w") as f:
json.dump(graphs, f, indent=2) json.dump(graphs, f, indent=2)
print(f"Wrote {tree_file}") print(f"Wrote {tree_file}")
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@ -0,0 +1,143 @@
import os
import sys
import json
import tempfile
import shutil
import unittest
from unittest.mock import patch
# Absoluter Pfad zum tree.py Script (wie im vorhandenen Test)
SCRIPT_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), "../../../../cli/build/tree.py")
)
class TestTreeIncludeRoleDependencies(unittest.TestCase):
def setUp(self):
# Temp roles root
self.roles_dir = tempfile.mkdtemp()
# Producer-Role (die wir scannen) + Zielrollen für Matches
self.producer = "producer"
self.producer_path = os.path.join(self.roles_dir, self.producer)
os.makedirs(os.path.join(self.producer_path, "tasks"))
os.makedirs(os.path.join(self.producer_path, "meta"))
# Rollen, die durch Pattern/Loops gematcht werden sollen
self.roles_to_create = [
"sys-ctl-hlth-webserver",
"sys-ctl-hlth-csp",
"svc-db-postgres",
"svc-db-mysql",
"axb", # für a{{database_type}}b → a*b
"ayyb", # für a{{database_type}}b → a*b
"literal-role", # für reinen Literalnamen
]
for r in self.roles_to_create:
os.makedirs(os.path.join(self.roles_dir, r, "meta"), exist_ok=True)
# tasks/main.yml mit allen geforderten Varianten
tasks_yaml = """
- name: Include health dependencies
include_role:
name: "{{ item }}"
loop:
- sys-ctl-hlth-webserver
- sys-ctl-hlth-csp
- name: Pattern with literal + var suffix
include_role:
name: "svc-db-{{database_type}}"
- name: Pattern with literal prefix/suffix around var
include_role:
name: "a{{database_type}}b"
- name: Pure variable only (should be ignored)
include_role:
name: "{{database_type}}"
- name: Pure literal include
include_role:
name: "literal-role"
"""
with open(os.path.join(self.producer_path, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks_yaml)
# shadow folder
self.shadow_dir = tempfile.mkdtemp()
# Patch argv
self.orig_argv = sys.argv[:]
sys.argv = [
SCRIPT_PATH,
"-d", self.roles_dir,
"-s", self.shadow_dir,
"-o", "json",
]
# Ensure project root on sys.path
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)
def tearDown(self):
sys.argv = self.orig_argv
shutil.rmtree(self.roles_dir)
shutil.rmtree(self.shadow_dir)
@patch("cli.build.tree.output_graph")
@patch("cli.build.tree.build_mappings")
def test_include_role_dependencies_detected(self, mock_build_mappings, mock_output_graph):
# Basis-Graph leer, damit nur unsere Dependencies sichtbar sind
mock_build_mappings.return_value = {}
# Import und Ausführen
import importlib
tree_mod = importlib.import_module("cli.build.tree")
tree_mod.main()
# Erwarteter Pfad im Shadow-Folder
expected_tree_path = os.path.join(
self.shadow_dir, self.producer, "meta", "tree.json"
)
self.assertTrue(
os.path.isfile(expected_tree_path),
f"tree.json not found at {expected_tree_path}"
)
# JSON laden und Abhängigkeiten prüfen
with open(expected_tree_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Erwartete include_role-Dependenzen:
expected = sorted([
"sys-ctl-hlth-webserver", # aus loop
"sys-ctl-hlth-csp", # aus loop
"svc-db-postgres", # aus svc-db-{{database_type}}
"svc-db-mysql", # aus svc-db-{{database_type}}
"axb", # aus a{{database_type}}b
"ayyb", # aus a{{database_type}}b
"literal-role", # reiner Literalname
])
deps = (
data
.get("dependencies", {})
.get("include_role", [])
)
self.assertEqual(deps, expected, "include_role dependencies mismatch")
# Sicherstellen, dass der pure Variable-Name "{{database_type}}" NICHT aufgenommen wurde
self.assertNotIn("{{database_type}}", deps, "pure variable include should be ignored")
# Sicherstellen, dass im Original-meta der Producer-Role nichts geschrieben wurde
original_tree_path = os.path.join(self.producer_path, "meta", "tree.json")
self.assertFalse(
os.path.exists(original_tree_path),
"tree.json should NOT be written to the real meta/ folder"
)
if __name__ == "__main__":
unittest.main()

View File

@ -17,7 +17,7 @@ class TestGetServiceName(unittest.TestCase):
def test_explicit_custom_suffix(self): def test_explicit_custom_suffix(self):
self.assertEqual( self.assertEqual(
get_service_name.get_service_name("sys-ctl-bkp@", "postgres", "special"), get_service_name.get_service_name("sys-ctl-bkp@", "postgres", "special"),
"sys-ctl-bkp.postgres@.special" "sys-ctl-bkp.postgres@special"
) )
def test_explicit_false_suffix(self): def test_explicit_false_suffix(self):
@ -32,7 +32,7 @@ class TestGetServiceName(unittest.TestCase):
def test_case_is_lowered(self): def test_case_is_lowered(self):
self.assertEqual( self.assertEqual(
get_service_name.get_service_name("Sys-CTL-BKP@", "Postgres", "SeRviCe"), get_service_name.get_service_name("Sys-CTL-BKP@", "Postgres", ".SeRviCe"),
"sys-ctl-bkp.postgres@.service" "sys-ctl-bkp.postgres@.service"
) )