mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-04 12:18:17 +00:00 
			
		
		
		
	Added invokable paths for role categories
This commit is contained in:
		
							
								
								
									
										58
									
								
								cli/invokable_paths.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										58
									
								
								cli/invokable_paths.py
									
									
									
									
									
										Executable file
									
								
							@@ -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()
 | 
			
		||||
							
								
								
									
										71
									
								
								filter_plugins/invokable_paths.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								filter_plugins/invokable_paths.py
									
									
									
									
									
										Normal file
									
								
							@@ -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 '<project_root>/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}
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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():
 | 
			
		||||
 
 | 
			
		||||
@@ -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}")
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										74
									
								
								tests/unit/filter_plugins/test_invokable_paths.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								tests/unit/filter_plugins/test_invokable_paths.py
									
									
									
									
									
										Normal file
									
								
							@@ -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()
 | 
			
		||||
		Reference in New Issue
	
	Block a user