mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-07-17 22:14:25 +02:00
Added invokable paths for role categories
This commit is contained in:
parent
12d833d20c
commit
74ebb375d0
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"
|
title: "Core & System"
|
||||||
description: "Fundamental system configuration"
|
description: "Fundamental system configuration"
|
||||||
icon: "fas fa-cogs"
|
icon: "fas fa-cogs"
|
||||||
|
invokable: false
|
||||||
drv:
|
drv:
|
||||||
title: "Drivers"
|
title: "Drivers"
|
||||||
description: "Roles for installing and configuring hardware drivers—covering printers, graphics, input devices, and other peripheral support."
|
description: "Roles for installing and configuring hardware drivers—covering printers, graphics, input devices, and other peripheral support."
|
||||||
icon: "fas fa-microchip"
|
icon: "fas fa-microchip"
|
||||||
|
invokable: true
|
||||||
gen:
|
gen:
|
||||||
title: "Generic"
|
title: "Generic"
|
||||||
description: "Helper roles & installers (git, locales, timer, etc.)"
|
description: "Helper roles & installers (git, locales, timer, etc.)"
|
||||||
icon: "fas fa-wrench"
|
icon: "fas fa-wrench"
|
||||||
|
invokable: false
|
||||||
desk:
|
desk:
|
||||||
title: "Desktop"
|
title: "Desktop"
|
||||||
description: "Desktop environment roles & apps (GNOME, browser, LibreOffice, etc.)"
|
description: "Desktop environment roles & apps (GNOME, browser, LibreOffice, etc.)"
|
||||||
icon: "fas fa-desktop"
|
icon: "fas fa-desktop"
|
||||||
|
invokable: true
|
||||||
util:
|
util:
|
||||||
title: "Utilities"
|
title: "Utilities"
|
||||||
description: "General-purpose utility roles for both desktop and server environments—providing helper functions, customizations, and optimizations for applications, workflows, and infrastructure."
|
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"
|
icon: "fas fa-tools"
|
||||||
|
invokable: false
|
||||||
desk:
|
desk:
|
||||||
title: "Desktop Utilities"
|
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."
|
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"
|
icon: "fas fa-tools"
|
||||||
|
invokable: true
|
||||||
srv:
|
srv:
|
||||||
title: "Server Utilities"
|
title: "Server Utilities"
|
||||||
description: "Utility roles for server-side configuration and management—covering corporate identity provisioning, network helpers, and other service-oriented toolkits."
|
description: "Utility roles for server-side configuration and management—covering corporate identity provisioning, network helpers, and other service-oriented toolkits."
|
||||||
icon: "fas fa-cogs"
|
icon: "fas fa-cogs"
|
||||||
|
invokable: true
|
||||||
srv:
|
srv:
|
||||||
title: "Server"
|
title: "Server"
|
||||||
description: "General server roles for provisioning and managing server infrastructure—covering web servers, proxy servers, network services, and other backend components."
|
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"
|
icon: "fas fa-server"
|
||||||
|
invokable: false
|
||||||
web:
|
web:
|
||||||
title: "Webserver"
|
title: "Webserver"
|
||||||
description: "Web-server roles for installing and configuring Nginx (core, TLS, injection filters, composer modules)."
|
description: "Web-server roles for installing and configuring Nginx (core, TLS, injection filters, composer modules)."
|
||||||
icon: "fas fa-server"
|
icon: "fas fa-server"
|
||||||
|
invokable: false
|
||||||
proxy:
|
proxy:
|
||||||
title: "Proxy Server"
|
title: "Proxy Server"
|
||||||
description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups."
|
description: "Proxy-server roles for virtual-host orchestration and reverse-proxy setups."
|
||||||
icon: "fas fa-project-diagram"
|
icon: "fas fa-project-diagram"
|
||||||
|
invokable: false
|
||||||
web:
|
web:
|
||||||
title: "Web Infrastructure"
|
title: "Web Infrastructure"
|
||||||
description: "Roles for managing web infrastructure—covering static content services and deployable web applications."
|
description: "Roles for managing web infrastructure—covering static content services and deployable web applications."
|
||||||
@ -47,42 +57,49 @@ roles:
|
|||||||
title: "Services"
|
title: "Services"
|
||||||
description: "Static content servers (assets, HTML, legal, files)"
|
description: "Static content servers (assets, HTML, legal, files)"
|
||||||
icon: "fas fa-file"
|
icon: "fas fa-file"
|
||||||
|
invokable: true
|
||||||
app:
|
app:
|
||||||
title: "Applications"
|
title: "Applications"
|
||||||
description: "Deployable web applications (GitLab, Nextcloud, Mastodon, etc.)"
|
description: "Deployable web applications (GitLab, Nextcloud, Mastodon, etc.)"
|
||||||
icon: "fas fa-docker"
|
icon: "fas fa-docker"
|
||||||
|
invokable: true
|
||||||
net:
|
net:
|
||||||
title: "Network"
|
title: "Network"
|
||||||
description: "Network setup (DNS, Let's Encrypt HTTP, WireGuard, etc.)"
|
description: "Network setup (DNS, Let's Encrypt HTTP, WireGuard, etc.)"
|
||||||
icon: "fas fa-globe"
|
icon: "fas fa-globe"
|
||||||
|
invokable: true
|
||||||
svc:
|
svc:
|
||||||
title: "Services"
|
title: "Services"
|
||||||
description: "Docker infrastructure services (DBMS, LDAP, Redis, etc.)"
|
description: "Docker infrastructure services (DBMS, LDAP, Redis, etc.)"
|
||||||
icon: "fas fa-database"
|
icon: "fas fa-database"
|
||||||
|
invokable: true
|
||||||
mon:
|
mon:
|
||||||
title: "Monitoring"
|
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."
|
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"
|
icon: "fas fa-chart-area"
|
||||||
|
invokable: false
|
||||||
alert:
|
alert:
|
||||||
title: "Alerting"
|
title: "Alerting"
|
||||||
description: "Notification handlers for system events"
|
description: "Notification handlers for system events"
|
||||||
icon: "fas fa-bell"
|
icon: "fas fa-bell"
|
||||||
|
invokable: false
|
||||||
maint:
|
maint:
|
||||||
title: "Maintenance & Healing"
|
title: "Maintenance & Healing"
|
||||||
description: "Periodic maintenance & auto-recovery"
|
description: "Periodic maintenance & auto-recovery"
|
||||||
icon: "fas fa-tools"
|
icon: "fas fa-tools"
|
||||||
|
invokable: true
|
||||||
bkp:
|
bkp:
|
||||||
title: "Backup & Restore"
|
title: "Backup & Restore"
|
||||||
description: "Backup strategies & restore procedures"
|
description: "Backup strategies & restore procedures"
|
||||||
icon: "fas fa-hdd"
|
icon: "fas fa-hdd"
|
||||||
|
invokable: false
|
||||||
update:
|
update:
|
||||||
title: "Updates & Package Management"
|
title: "Updates & Package Management"
|
||||||
description: "OS & package updates"
|
description: "OS & package updates"
|
||||||
icon: "fas fa-sync"
|
icon: "fas fa-sync"
|
||||||
|
invokable: true
|
||||||
user:
|
user:
|
||||||
title: "Users & Access"
|
title: "Users & Access"
|
||||||
description: "User accounts & access control"
|
description: "User accounts & access control"
|
||||||
icon: "fas fa-users"
|
icon: "fas fa-users"
|
||||||
|
invokable: false
|
||||||
|
@ -2,6 +2,7 @@ import os
|
|||||||
import yaml
|
import yaml
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from filter_plugins.invokable_paths import get_invokable_paths
|
||||||
|
|
||||||
ROLES_DIR = Path(__file__).resolve().parent.parent.parent / "roles"
|
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):
|
class TestApplicationIdConsistency(unittest.TestCase):
|
||||||
def test_application_id_matches_docker_prefix(self):
|
def test_application_id_matches_docker_prefix(self):
|
||||||
failed_roles = []
|
failed_roles = []
|
||||||
prefixes = ("web-app-", "web-svc-", "desk-", "util-", "drv-")
|
prefixes = (get_invokable_paths(suffix="-"))
|
||||||
|
|
||||||
for role_path in ROLES_DIR.iterdir():
|
for role_path in ROLES_DIR.iterdir():
|
||||||
if not role_path.is_dir():
|
if not role_path.is_dir():
|
||||||
|
@ -28,7 +28,8 @@ class TestCategoryPaths(unittest.TestCase):
|
|||||||
|
|
||||||
# Nested subcategories (keys other than metadata)
|
# Nested subcategories (keys other than metadata)
|
||||||
for sub_key in attrs:
|
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
|
continue
|
||||||
expected.add(f"{top_key}-{sub_key}")
|
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()
|
Loading…
x
Reference in New Issue
Block a user