Restructured CLI logic

This commit is contained in:
Kevin Veen-Birkenbach 2025-07-10 21:26:44 +02:00
parent 8457325b5c
commit c160c58a5c
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
44 changed files with 97 additions and 155 deletions

View File

@ -1,9 +1,9 @@
ROLES_DIR := ./roles ROLES_DIR := ./roles
APPLICATIONS_OUT := ./group_vars/all/04_applications.yml APPLICATIONS_OUT := ./group_vars/all/04_applications.yml
APPLICATIONS_SCRIPT := ./cli/generate_applications.py APPLICATIONS_SCRIPT := ./cli/generate/defaults/applications.py
USERS_OUT := ./group_vars/all/03_users.yml USERS_OUT := ./group_vars/all/03_users.yml
USERS_SCRIPT := ./cli/generate_users.py USERS_SCRIPT := ./cli/generate/defaults/users.py
INCLUDES_SCRIPT := ./cli/generate_playbook.py INCLUDES_SCRIPT := ./cli/generate/conditional_role_include.py
# Define the prefixes for which we want individual role-include files # Define the prefixes for which we want individual role-include files
INCLUDE_GROUPS := "drv-" "svc-" "desk-" "web-" "util-" INCLUDE_GROUPS := "drv-" "svc-" "desk-" "web-" "util-"

View File

@ -5,8 +5,8 @@ from pathlib import Path
import yaml import yaml
from typing import Dict, Any from typing import Dict, Any
from utils.manager.inventory import InventoryManager from utils.manager.inventory import InventoryManager
from utils.handler.vault import VaultHandler, VaultScalar from utils.handler.vault import VaultHandler, VaultScalar
from utils.handler.yaml import YamlHandler from utils.handler.yaml import YamlHandler
from yaml.dumper import SafeDumper from yaml.dumper import SafeDumper

View File

@ -21,7 +21,7 @@ def run_ansible_playbook(inventory, modes, limit=None, allowed_applications=None
print("\n🔍 Validating inventory before deployment...\n") print("\n🔍 Validating inventory before deployment...\n")
try: try:
subprocess.run( subprocess.run(
[sys.executable, os.path.join(script_dir, "validate_inventory.py"), os.path.dirname(inventory)], [sys.executable, os.path.join(script_dir, "validate.inventory.py"), os.path.dirname(inventory)],
check=True check=True
) )
except subprocess.CalledProcessError: except subprocess.CalledProcessError:

0
cli/fix/__init__.py Normal file
View File

47
cli/fix/ini_py.py Normal file
View File

@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
This script creates __init__.py files in every subdirectory under the specified
folder relative to the project root.
"""
import os
import argparse
def create_init_files(root_folder):
"""
Walk through all subdirectories of root_folder and create an __init__.py file
in each directory if it doesn't already exist.
"""
for dirpath, dirnames, filenames in os.walk(root_folder):
init_file = os.path.join(dirpath, '__init__.py')
if not os.path.exists(init_file):
open(init_file, 'w').close()
print(f"Created: {init_file}")
else:
print(f"Skipped (already exists): {init_file}")
def main():
parser = argparse.ArgumentParser(
description='Create __init__.py files in every subdirectory.'
)
parser.add_argument(
'folder',
help='Relative path to the target folder (e.g., cli/fix)'
)
args = parser.parse_args()
# Determine the absolute path based on the current working directory
root_folder = os.path.abspath(args.folder)
if not os.path.isdir(root_folder):
print(f"Error: The folder '{args.folder}' does not exist or is not a directory.")
exit(1)
create_init_files(root_folder)
if __name__ == '__main__':
main()

0
cli/generate/__init__.py Normal file
View File

View File

@ -156,7 +156,7 @@ def print_dependency_tree(graph):
for root in roots: for root in roots:
print_node(root) print_node(root)
def generate_playbook_entries(roles_dir, prefixes=None): def gen_condi_role_incl(roles_dir, prefixes=None):
""" """
Generate playbook entries based on the sorted order. Generate playbook entries based on the sorted order.
Raises a ValueError if application_id is missing. Raises a ValueError if application_id is missing.
@ -209,7 +209,7 @@ def main():
print_dependency_tree(graph) print_dependency_tree(graph)
sys.exit(0) sys.exit(0)
entries = generate_playbook_entries(args.roles_dir, prefixes) entries = gen_condi_role_incl(args.roles_dir, prefixes)
output = ''.join(entries) output = ''.join(entries)
if args.output: if args.output:

View File

View File

@ -6,7 +6,7 @@ import yaml
import sys import sys
from pathlib import Path from pathlib import Path
plugin_path = Path(__file__).resolve().parent / ".." / "lookup_plugins" plugin_path = Path(__file__).resolve().parent / ".." / ".." / ".." /"lookup_plugins"
sys.path.insert(0, str(plugin_path)) sys.path.insert(0, str(plugin_path))
from application_gid import LookupModule from application_gid import LookupModule

0
cli/meta/__init__.py Normal file
View File

View File

@ -1,105 +0,0 @@
import argparse
import subprocess
from ansible.parsing.vault import VaultLib, VaultSecret
import sys
import yaml
import re
from utils.handler.vault import VaultScalar
from yaml.loader import SafeLoader
from yaml.dumper import SafeDumper
# Register the custom constructor and representer for VaultScalar in PyYAML
SafeLoader.add_constructor('!vault', lambda loader, node: VaultScalar(node.value))
SafeDumper.add_representer(VaultScalar, lambda dumper, data: dumper.represent_scalar('!vault', data))
def is_vault_encrypted_data(data: str) -> bool:
"""Check if the given data is encrypted with Ansible Vault by looking for the vault header."""
return data.lstrip().startswith('$ANSIBLE_VAULT')
def decrypt_vault_data(encrypted_data: str, vault_secret: VaultSecret) -> str:
"""
Decrypt the given encrypted data using the provided vault_secret.
:param encrypted_data: Encrypted string to be decrypted
:param vault_secret: The VaultSecret instance used to decrypt the data
:return: Decrypted data as a string
"""
vault = VaultLib()
decrypted_data = vault.decrypt(encrypted_data, vault_secret)
return decrypted_data
def decrypt_vault_file(vault_file: str, vault_password_file: str):
"""
Decrypt the Ansible Vault file and return its contents.
:param vault_file: Path to the encrypted Ansible Vault file
:param vault_password_file: Path to the file containing the Vault password
:return: Decrypted contents of the Vault file
"""
# Read the vault password
with open(vault_password_file, 'r') as f:
vault_password = f.read().strip()
# Create a VaultSecret instance from the password
vault_secret = VaultSecret(vault_password.encode())
# Read the encrypted file
with open(vault_file, 'r') as f:
file_content = f.read()
# If the file is partially encrypted, we'll decrypt only the encrypted values
decrypted_data = file_content # Start with the unmodified content
# Find all vault-encrypted values (i.e., values starting with $ANSIBLE_VAULT)
encrypted_values = re.findall(r'^\s*([\w\.\-_]+):\s*["\']?\$ANSIBLE_VAULT[^\n]+', file_content, flags=re.MULTILINE)
# If there are encrypted values, decrypt them
for value in encrypted_values:
# Extract the encrypted value and decrypt it
encrypted_value = re.search(r'(["\']?\$ANSIBLE_VAULT[^\n]+)', value)
if encrypted_value:
# Remove any newlines or extra spaces from the encrypted value
encrypted_value = encrypted_value.group(0).replace('\n', '').replace('\r', '')
decrypted_value = decrypt_vault_data(encrypted_value, vault_secret)
# Replace the encrypted value with the decrypted value in the content
decrypted_data = decrypted_data.replace(encrypted_value, decrypted_value.strip())
return decrypted_data
def decrypt_and_display(vault_file: str, vault_password_file: str):
"""
Decrypts the Ansible Vault file and its values, then display the result.
Supports both full file and partial value encryption.
:param vault_file: Path to the encrypted Ansible Vault file
:param vault_password_file: Path to the file containing the Vault password
"""
decrypted_data = decrypt_vault_file(vault_file, vault_password_file)
# Convert the decrypted data to a string format (YAML or JSON)
output_data = yaml.dump(yaml.safe_load(decrypted_data), default_flow_style=False)
# Use subprocess to call `less` for paginated, scrollable output
subprocess.run(["less"], input=output_data, text=True)
def main():
# Set up the argument parser
parser = argparse.ArgumentParser(description="Decrypt and display variables from an Ansible Vault file.")
# Add arguments for the vault file and vault password file
parser.add_argument(
'vault_file',
type=str,
help="Path to the encrypted Ansible Vault file"
)
parser.add_argument(
'vault_password_file',
type=str,
help="Path to the file containing the Vault password"
)
# Parse the arguments
args = parser.parse_args()
# Display vault variables in a scrollable manner
decrypt_and_display(args.vault_file, args.vault_password_file)
if __name__ == "__main__":
main()

0
cli/validate/__init__.py Normal file
View File

View File

@ -2,7 +2,7 @@ import os
import unittest import unittest
# import the functions from your CLI script # import the functions from your CLI script
from cli.generate_playbook import build_dependency_graph, find_cycle from cli.generate.conditional_role_include import build_dependency_graph, find_cycle
class TestCircularDependencies(unittest.TestCase): class TestCircularDependencies(unittest.TestCase):
""" """

View File

View File

@ -10,7 +10,7 @@ dir_path = os.path.abspath(
sys.path.insert(0, dir_path) sys.path.insert(0, dir_path)
# Import functions and classes to test # Import functions and classes to test
from create_credentials import ask_for_confirmation, main from cli.create.credentials import ask_for_confirmation, main
from utils.handler.vault import VaultHandler, VaultScalar from utils.handler.vault import VaultHandler, VaultScalar
import subprocess import subprocess
import tempfile import tempfile
@ -81,7 +81,7 @@ class TestCreateCredentials(unittest.TestCase):
with mock.patch('subprocess.run', return_value=completed): with mock.patch('subprocess.run', return_value=completed):
# Run main with override for credentials.api_key and force to skip prompt # Run main with override for credentials.api_key and force to skip prompt
sys.argv = [ sys.argv = [
'create_credentials.py', 'create/credentials.py',
'--role-path', role_path, '--role-path', role_path,
'--inventory-file', inventory_file, '--inventory-file', inventory_file,
'--vault-password-file', vault_pw_file, '--vault-password-file', vault_pw_file,

View File

@ -6,7 +6,7 @@ import tempfile
from ruamel.yaml import YAML from ruamel.yaml import YAML
# Import functions to test; adjust path as needed # Import functions to test; adjust path as needed
from cli.create_web_app import ( from cli.create.role import (
get_next_network, get_next_network,
get_next_port, get_next_port,
load_yaml_with_comments, load_yaml_with_comments,

View File

View File

@ -1,4 +1,4 @@
# tests/cli/test_ensure_vars_main.py # tests/cli/test_fix/vars_main_files.py
import os import os
import shutil import shutil
import tempfile import tempfile
@ -6,7 +6,7 @@ import unittest
import yaml import yaml
# Adjust this import to match the real path in your project # Adjust this import to match the real path in your project
from cli.ensure_vars_main import run, ROLES_DIR from cli.ensure.vars_main_files import run, ROLES_DIR
class TestEnsureVarsMain(unittest.TestCase): class TestEnsureVarsMain(unittest.TestCase):
def setUp(self): def setUp(self):
@ -17,11 +17,11 @@ class TestEnsureVarsMain(unittest.TestCase):
# Monkey-patch the module's ROLES_DIR to point here # Monkey-patch the module's ROLES_DIR to point here
self._orig_roles_dir = ROLES_DIR self._orig_roles_dir = ROLES_DIR
setattr(__import__("cli.ensure_vars_main", fromlist=["ROLES_DIR"]), "ROLES_DIR", self.roles_dir) setattr(__import__("cli.ensure.vars_main_files", fromlist=["ROLES_DIR"]), "ROLES_DIR", self.roles_dir)
def tearDown(self): def tearDown(self):
# restore and cleanup # restore and cleanup
setattr(__import__("cli.ensure_vars_main", fromlist=["ROLES_DIR"]), "ROLES_DIR", self._orig_roles_dir) setattr(__import__("cli.ensure.vars_main_files", fromlist=["ROLES_DIR"]), "ROLES_DIR", self._orig_roles_dir)
shutil.rmtree(self.tmpdir) shutil.rmtree(self.tmpdir)
def _make_role(self, name, vars_content=None): def _make_role(self, name, vars_content=None):

View File

View File

@ -7,9 +7,9 @@ import shutil
import yaml import yaml
# Adjust path to include cli/ folder # Adjust path to include cli/ folder
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "cli"))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..", "cli")))
from generate_playbook import build_dependency_graph, topological_sort, generate_playbook_entries from cli.generate.conditional_role_include import build_dependency_graph, topological_sort, gen_condi_role_incl
class TestGeneratePlaybook(unittest.TestCase): class TestGeneratePlaybook(unittest.TestCase):
def setUp(self): def setUp(self):
@ -66,8 +66,8 @@ class TestGeneratePlaybook(unittest.TestCase):
# The expected order must be a → b → c, d can be anywhere before or after # The expected order must be a → b → c, d can be anywhere before or after
self.assertTrue(sorted_roles.index('role-a') < sorted_roles.index('role-b') < sorted_roles.index('role-c')) self.assertTrue(sorted_roles.index('role-a') < sorted_roles.index('role-b') < sorted_roles.index('role-c'))
def test_generate_playbook_entries(self): def test_gen_condi_role_incl(self):
entries = generate_playbook_entries(self.temp_dir) entries = gen_condi_role_incl(self.temp_dir)
text = ''.join(entries) text = ''.join(entries)
self.assertIn("setup a", text) self.assertIn("setup a", text)

View File

@ -30,7 +30,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
shutil.rmtree(self.temp_dir) shutil.rmtree(self.temp_dir)
def test_script_generates_expected_yaml(self): def test_script_generates_expected_yaml(self):
script_path = Path(__file__).resolve().parent.parent.parent.parent / "cli" / "generate_applications.py" script_path = Path(__file__).resolve().parent.parent.parent.parent.parent.parent / "cli/generate/defaults/applications.py"
result = subprocess.run( result = subprocess.run(
[ [

View File

@ -45,7 +45,7 @@ class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
When a users.yml exists with defined users, the script should inject a 'users' When a users.yml exists with defined users, the script should inject a 'users'
mapping in the generated YAML, mapping each username to a Jinja2 reference. mapping in the generated YAML, mapping each username to a Jinja2 reference.
""" """
script_path = Path(__file__).resolve().parents[3] / "cli" / "generate_applications.py" script_path = Path(__file__).resolve().parents[5] / "cli" / "generate/defaults/applications.py"
result = subprocess.run([ result = subprocess.run([
"python3", str(script_path), "python3", str(script_path),
"--roles-dir", str(self.roles_dir), "--roles-dir", str(self.roles_dir),

View File

@ -7,9 +7,9 @@ import yaml
from collections import OrderedDict from collections import OrderedDict
# Add cli/ to import path # Add cli/ to import path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../..", "cli"))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../..", "cli/generate/defaults/")))
import generate_users import users
class TestGenerateUsers(unittest.TestCase): class TestGenerateUsers(unittest.TestCase):
def test_build_users_auto_increment_and_overrides(self): def test_build_users_auto_increment_and_overrides(self):
@ -18,24 +18,24 @@ class TestGenerateUsers(unittest.TestCase):
'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'}, 'bob': {'uid': 2000, 'email': 'bob@custom.com', 'description': 'Custom user'},
'carol': {} 'carol': {}
} }
users = generate_users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain='example.com',
start_id=1001, start_id=1001,
become_pwd='pw' become_pwd='pw'
) )
# alice should get uid/gid 1001 # alice should get uid/gid 1001
self.assertEqual(users['alice']['uid'], 1001) self.assertEqual(build['alice']['uid'], 1001)
self.assertEqual(users['alice']['gid'], 1001) self.assertEqual(build['alice']['gid'], 1001)
self.assertEqual(users['alice']['email'], 'alice@example.com') self.assertEqual(build['alice']['email'], 'alice@example.com')
# bob overrides # bob overrides
self.assertEqual(users['bob']['uid'], 2000) self.assertEqual(build['bob']['uid'], 2000)
self.assertEqual(users['bob']['gid'], 2000) self.assertEqual(build['bob']['gid'], 2000)
self.assertEqual(users['bob']['email'], 'bob@custom.com') self.assertEqual(build['bob']['email'], 'bob@custom.com')
self.assertIn('description', users['bob']) self.assertIn('description', build['bob'])
# carol should get next free id = 1002 # carol should get next free id = 1002
self.assertEqual(users['carol']['uid'], 1002) self.assertEqual(build['carol']['uid'], 1002)
self.assertEqual(users['carol']['gid'], 1002) self.assertEqual(build['carol']['gid'], 1002)
def test_build_users_default_lookup_password(self): def test_build_users_default_lookup_password(self):
""" """
@ -44,14 +44,14 @@ class TestGenerateUsers(unittest.TestCase):
""" """
defs = {'frank': {}} defs = {'frank': {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain='example.com',
start_id=1001, start_id=1001,
become_pwd=lookup_template become_pwd=lookup_template
) )
self.assertEqual( self.assertEqual(
users['frank']['password'], build['frank']['password'],
lookup_template, lookup_template,
"The lookup template string was not correctly applied as the default password" "The lookup template string was not correctly applied as the default password"
) )
@ -63,14 +63,14 @@ class TestGenerateUsers(unittest.TestCase):
""" """
defs = {'eva': {'password': 'custompw'}} defs = {'eva': {'password': 'custompw'}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}' lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users( build = users.build_users(
defs=defs, defs=defs,
primary_domain='example.com', primary_domain='example.com',
start_id=1001, start_id=1001,
become_pwd=lookup_template become_pwd=lookup_template
) )
self.assertEqual( self.assertEqual(
users['eva']['password'], build['eva']['password'],
'custompw', 'custompw',
"The override password was not correctly applied" "The override password was not correctly applied"
) )
@ -82,7 +82,7 @@ class TestGenerateUsers(unittest.TestCase):
'u2': {'uid': 1001} 'u2': {'uid': 1001}
} }
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generate_users.build_users(defs, 'ex.com', 1001, 'pw') users.build_users(defs, 'ex.com', 1001, 'pw')
def test_build_users_shared_gid_allowed(self): def test_build_users_shared_gid_allowed(self):
# Allow two users to share the same GID when one overrides gid and the other uses that as uid # Allow two users to share the same GID when one overrides gid and the other uses that as uid
@ -90,10 +90,10 @@ class TestGenerateUsers(unittest.TestCase):
'a': {'uid': 1500}, 'a': {'uid': 1500},
'b': {'gid': 1500} 'b': {'gid': 1500}
} }
users = generate_users.build_users(defs, 'ex.com', 1500, 'pw') build = users.build_users(defs, 'ex.com', 1500, 'pw')
# Both should have gid 1500 # Both should have gid 1500
self.assertEqual(users['a']['gid'], 1500) self.assertEqual(build['a']['gid'], 1500)
self.assertEqual(users['b']['gid'], 1500) self.assertEqual(build['b']['gid'], 1500)
def test_build_users_duplicate_username_email(self): def test_build_users_duplicate_username_email(self):
defs = { defs = {
@ -102,11 +102,11 @@ class TestGenerateUsers(unittest.TestCase):
} }
# second user with same username should raise # second user with same username should raise
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generate_users.build_users(defs, 'ex.com', 1001, 'pw') users.build_users(defs, 'ex.com', 1001, 'pw')
def test_dictify_converts_ordereddict(self): def test_dictify_converts_ordereddict(self):
od = generate_users.OrderedDict([('a', 1), ('b', {'c': 2})]) od = users.OrderedDict([('a', 1), ('b', {'c': 2})])
result = generate_users.dictify(OrderedDict(od)) result = users.dictify(OrderedDict(od))
self.assertIsInstance(result, dict) self.assertIsInstance(result, dict)
self.assertEqual(result, {'a': 1, 'b': {'c': 2}}) self.assertEqual(result, {'a': 1, 'b': {'c': 2}})
@ -122,13 +122,13 @@ class TestGenerateUsers(unittest.TestCase):
# role2 defines same user x with same value # role2 defines same user x with same value
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f) yaml.safe_dump({'users': {'x': {'email': 'x@a'}}}, f)
defs = generate_users.load_user_defs(tmp) defs = users.load_user_defs(tmp)
self.assertIn('x', defs) self.assertIn('x', defs)
# now conflict definition # now conflict definition
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f: with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f) yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
generate_users.load_user_defs(tmp) users.load_user_defs(tmp)
finally: finally:
shutil.rmtree(tmp) shutil.rmtree(tmp)

View File

@ -16,7 +16,7 @@ sys.path.insert(
from utils.handler.yaml import YamlHandler from utils.handler.yaml import YamlHandler
from utils.handler.vault import VaultHandler, VaultScalar from utils.handler.vault import VaultHandler, VaultScalar
from cli.utils.manager.inventory import InventoryManager from utils.manager.inventory import InventoryManager
class TestInventoryManager(unittest.TestCase): class TestInventoryManager(unittest.TestCase):

View File

View File

@ -7,7 +7,7 @@ import subprocess
import sys import sys
import yaml import yaml
SCRIPT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../cli/validate_inventory.py")) SCRIPT_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../cli/validate/inventory.py"))
class TestValidateInventory(unittest.TestCase): class TestValidateInventory(unittest.TestCase):
def setUp(self): def setUp(self):

0
utils/__init__.py Normal file
View File

View File

View File