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

@ -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