diff --git a/cli/invokable_paths.py b/cli/invokable_paths.py new file mode 100755 index 00000000..26c3b0e5 --- /dev/null +++ b/cli/invokable_paths.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +CLI for extracting invokable role paths from a nested roles YAML file using argparse. +Assumes a default roles file at the project root if none is provided. +""" + +import os +import sys + +# ─── Determine project root ─── +if "__file__" in globals(): + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +else: + project_root = os.getcwd() + +# Ensure project root on PYTHONPATH so 'filter_plugins' can be imported +sys.path.insert(0, project_root) + +import argparse +import yaml +from filter_plugins.invokable_paths import get_invokable_paths + + +def main(): + parser = argparse.ArgumentParser( + description="Extract invokable role paths from a nested roles YAML file." + ) + parser.add_argument( + "roles_file", + nargs='?', + default=None, + help="Path to the roles YAML file (default: roles/categories.yml at project root)" + ) + parser.add_argument( + "--suffix", "-s", + help="Optional suffix to append to each invokable path.", + default=None + ) + args = parser.parse_args() + + try: + paths = get_invokable_paths(args.roles_file, args.suffix) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except yaml.YAMLError as e: + print(f"Error parsing YAML: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + for p in paths: + print(p) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/filter_plugins/invokable_paths.py b/filter_plugins/invokable_paths.py new file mode 100644 index 00000000..4cd8f050 --- /dev/null +++ b/filter_plugins/invokable_paths.py @@ -0,0 +1,71 @@ +import os +import yaml +from typing import Dict, List, Optional + + +def get_invokable_paths( + roles_file: Optional[str] = None, + suffix: Optional[str] = None +) -> List[str]: + """ + Load nested roles YAML from the given file (or default at project root) and return + dash-joined paths where 'invokable' is True. Appends suffix if provided. + + :param roles_file: Optional path to YAML file. Defaults to '/roles/categories.yml'. + :param suffix: Optional suffix to append to each invokable path. + :return: List of invokable paths. + :raises FileNotFoundError: If the YAML file cannot be found. + :raises yaml.YAMLError: If the YAML file cannot be parsed. + :raises ValueError: If the root of the YAML is not a dictionary. + """ + # Determine default roles_file if not provided + if not roles_file: + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + roles_file = os.path.join(project_root, 'roles', 'categories.yml') + + # Load and validate YAML + try: + with open(roles_file, 'r') as f: + data = yaml.safe_load(f) or {} + except FileNotFoundError: + raise FileNotFoundError(f"Roles file not found: {roles_file}") + except yaml.YAMLError as e: + raise yaml.YAMLError(f"Error parsing YAML {roles_file}: {e}") + + if not isinstance(data, dict): + raise ValueError("YAML root is not a dictionary") + + # Unwrap if single 'roles' key + roles = data + if 'roles' in roles and isinstance(roles['roles'], dict) and len(roles) == 1: + roles = roles['roles'] + + def _recurse(subroles: Dict[str, dict], parent: List[str] = None) -> List[str]: + parent = parent or [] + found: List[str] = [] + METADATA = {'title', 'description', 'icon', 'invokable'} + + for key, cfg in subroles.items(): + path = parent + [key] + if cfg.get('invokable', False): + p = '-'.join(path) + if suffix: + p += suffix + found.append(p) + + # Recurse into non-metadata child dicts + children = { + ck: cv for ck, cv in cfg.items() + if ck not in METADATA and isinstance(cv, dict) + } + if children: + found.extend(_recurse(children, path)) + return found + + return _recurse(roles) + + +class FilterModule: + def filters(self): + return {'invokable_paths': get_invokable_paths} diff --git a/roles/categories.yml b/roles/categories.yml index 1e6bea40..064f8aaa 100644 --- a/roles/categories.yml +++ b/roles/categories.yml @@ -3,42 +3,52 @@ roles: title: "Core & System" description: "Fundamental system configuration" icon: "fas fa-cogs" + invokable: false drv: title: "Drivers" description: "Roles for installing and configuring hardware drivers—covering printers, graphics, input devices, and other peripheral support." icon: "fas fa-microchip" + invokable: true gen: title: "Generic" description: "Helper roles & installers (git, locales, timer, etc.)" icon: "fas fa-wrench" + invokable: false desk: title: "Desktop" description: "Desktop environment roles & apps (GNOME, browser, LibreOffice, etc.)" icon: "fas fa-desktop" + invokable: true util: title: "Utilities" description: "General-purpose utility roles for both desktop and server environments—providing helper functions, customizations, and optimizations for applications, workflows, and infrastructure." icon: "fas fa-tools" + invokable: false desk: title: "Desktop Utilities" description: "Utility roles for configuring and optimizing desktop applications and workflows—covering browsers, design tools, development environments, office suites, and gaming setups." icon: "fas fa-tools" + invokable: true srv: title: "Server Utilities" description: "Utility roles for server-side configuration and management—covering corporate identity provisioning, network helpers, and other service-oriented toolkits." icon: "fas fa-cogs" + invokable: true srv: title: "Server" description: "General server roles for provisioning and managing server infrastructure—covering web servers, proxy servers, network services, and other backend components." icon: "fas fa-server" + invokable: false web: title: "Webserver" description: "Web-server roles for installing and configuring Nginx (core, TLS, injection filters, composer modules)." icon: "fas fa-server" + invokable: false proxy: title: "Proxy Server" description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups." icon: "fas fa-project-diagram" + invokable: false web: title: "Web Infrastructure" description: "Roles for managing web infrastructure—covering static content services and deployable web applications." @@ -47,42 +57,49 @@ roles: title: "Services" description: "Static content servers (assets, HTML, legal, files)" icon: "fas fa-file" + invokable: true app: title: "Applications" description: "Deployable web applications (GitLab, Nextcloud, Mastodon, etc.)" icon: "fas fa-docker" + invokable: true net: title: "Network" description: "Network setup (DNS, Let's Encrypt HTTP, WireGuard, etc.)" icon: "fas fa-globe" + invokable: true svc: title: "Services" description: "Docker infrastructure services (DBMS, LDAP, Redis, etc.)" icon: "fas fa-database" + invokable: true mon: title: "Monitoring" description: "Roles for system monitoring and health checks—encompassing bot-style automated checks and core low-level monitors for logs, containers, disk usage, and more." icon: "fas fa-chart-area" - + invokable: false alert: title: "Alerting" description: "Notification handlers for system events" icon: "fas fa-bell" + invokable: false maint: title: "Maintenance & Healing" description: "Periodic maintenance & auto-recovery" icon: "fas fa-tools" + invokable: true bkp: title: "Backup & Restore" description: "Backup strategies & restore procedures" icon: "fas fa-hdd" - + invokable: false update: title: "Updates & Package Management" description: "OS & package updates" icon: "fas fa-sync" - + invokable: true user: title: "Users & Access" description: "User accounts & access control" icon: "fas fa-users" + invokable: false diff --git a/tests/integration/test_application_id_consistency.py b/tests/integration/test_application_id_consistency.py index fc7e13a3..f07db22e 100644 --- a/tests/integration/test_application_id_consistency.py +++ b/tests/integration/test_application_id_consistency.py @@ -2,6 +2,7 @@ import os import yaml import unittest from pathlib import Path +from filter_plugins.invokable_paths import get_invokable_paths ROLES_DIR = Path(__file__).resolve().parent.parent.parent / "roles" @@ -9,7 +10,7 @@ ROLES_DIR = Path(__file__).resolve().parent.parent.parent / "roles" class TestApplicationIdConsistency(unittest.TestCase): def test_application_id_matches_docker_prefix(self): failed_roles = [] - prefixes = ("web-app-", "web-svc-", "desk-", "util-", "drv-") + prefixes = (get_invokable_paths(suffix="-")) for role_path in ROLES_DIR.iterdir(): if not role_path.is_dir(): diff --git a/tests/integration/test_categories_paths.py b/tests/integration/test_categories_paths.py index 808b4d0c..2d57ab14 100644 --- a/tests/integration/test_categories_paths.py +++ b/tests/integration/test_categories_paths.py @@ -28,7 +28,8 @@ class TestCategoryPaths(unittest.TestCase): # Nested subcategories (keys other than metadata) for sub_key in attrs: - if sub_key in ('title', 'description', 'icon', 'children'): + # Skip metadata keys + if sub_key in ('title', 'description', 'icon', 'children', 'invokable'): continue expected.add(f"{top_key}-{sub_key}") diff --git a/tests/unit/filter_plugins/test_invokable_paths.py b/tests/unit/filter_plugins/test_invokable_paths.py new file mode 100644 index 00000000..c0c227db --- /dev/null +++ b/tests/unit/filter_plugins/test_invokable_paths.py @@ -0,0 +1,74 @@ + +import unittest +import tempfile +import yaml +import os +from filter_plugins.invokable_paths import get_invokable_paths + + +class TestInvokablePaths(unittest.TestCase): + def write_yaml(self, data): + tmp = tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') + yaml.dump(data, tmp) + tmp.close() + return tmp.name + + def test_empty_roles(self): + path = self.write_yaml({}) + self.assertEqual(get_invokable_paths(path), []) + os.unlink(path) + + def test_single_invokable_true(self): + data = {'role1': {'invokable': True}} + path = self.write_yaml(data) + self.assertEqual(get_invokable_paths(path), ['role1']) + os.unlink(path) + + def test_single_invokable_false_or_missing(self): + data_false = {'role1': {'invokable': False}} + path_false = self.write_yaml(data_false) + self.assertEqual(get_invokable_paths(path_false), []) + os.unlink(path_false) + + data_missing = {'role1': {}} + path_missing = self.write_yaml(data_missing) + self.assertEqual(get_invokable_paths(path_missing), []) + os.unlink(path_missing) + + def test_nested_and_deeply_nested(self): + data = { + 'parent': { + 'invokable': True, + 'child': {'invokable': True}, + 'other': {'invokable': False}, + 'sub': { + 'deep': {'invokable': True} + } + } + } + path = self.write_yaml(data) + expected = ['parent', 'parent-child', 'parent-sub-deep'] + self.assertEqual(sorted(get_invokable_paths(path)), sorted(expected)) + os.unlink(path) + + def test_ignore_metadata_and_unwrap(self): + data = {'roles': { + 'role1': { + 'invokable': True, + 'title': {'foo': 'bar'}, + 'description': {'bar': 'baz'} + } + }} + path = self.write_yaml(data) + self.assertEqual(get_invokable_paths(path), ['role1']) + os.unlink(path) + + def test_suffix_appended(self): + data = {'role1': {'invokable': True}} + path = self.write_yaml(data) + self.assertEqual(get_invokable_paths(path, suffix='_suf'), ['role1_suf']) + os.unlink(path) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file