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
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_SCRIPT := ./cli/generate_users.py
INCLUDES_SCRIPT := ./cli/generate_playbook.py
USERS_SCRIPT := ./cli/generate/defaults/users.py
INCLUDES_SCRIPT := ./cli/generate/conditional_role_include.py
# Define the prefixes for which we want individual role-include files
INCLUDE_GROUPS := "drv-" "svc-" "desk-" "web-" "util-"

View File

@ -5,8 +5,8 @@ from pathlib import Path
import yaml
from typing import Dict, Any
from utils.manager.inventory import InventoryManager
from utils.handler.vault import VaultHandler, VaultScalar
from utils.handler.yaml import YamlHandler
from utils.handler.vault import VaultHandler, VaultScalar
from utils.handler.yaml import YamlHandler
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")
try:
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
)
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:
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.
Raises a ValueError if application_id is missing.
@ -209,7 +209,7 @@ def main():
print_dependency_tree(graph)
sys.exit(0)
entries = generate_playbook_entries(args.roles_dir, prefixes)
entries = gen_condi_role_incl(args.roles_dir, prefixes)
output = ''.join(entries)
if args.output:

View File

View File

@ -6,7 +6,7 @@ import yaml
import sys
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))
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 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):
"""

View File

View File

@ -10,7 +10,7 @@ dir_path = os.path.abspath(
sys.path.insert(0, dir_path)
# 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
import subprocess
import tempfile
@ -81,7 +81,7 @@ class TestCreateCredentials(unittest.TestCase):
with mock.patch('subprocess.run', return_value=completed):
# Run main with override for credentials.api_key and force to skip prompt
sys.argv = [
'create_credentials.py',
'create/credentials.py',
'--role-path', role_path,
'--inventory-file', inventory_file,
'--vault-password-file', vault_pw_file,

View File

@ -6,7 +6,7 @@ import tempfile
from ruamel.yaml import YAML
# Import functions to test; adjust path as needed
from cli.create_web_app import (
from cli.create.role import (
get_next_network,
get_next_port,
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 shutil
import tempfile
@ -6,7 +6,7 @@ import unittest
import yaml
# 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):
def setUp(self):
@ -17,11 +17,11 @@ class TestEnsureVarsMain(unittest.TestCase):
# Monkey-patch the module's ROLES_DIR to point here
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):
# 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)
def _make_role(self, name, vars_content=None):

View File

View File

@ -7,9 +7,9 @@ import shutil
import yaml
# 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):
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
self.assertTrue(sorted_roles.index('role-a') < sorted_roles.index('role-b') < sorted_roles.index('role-c'))
def test_generate_playbook_entries(self):
entries = generate_playbook_entries(self.temp_dir)
def test_gen_condi_role_incl(self):
entries = gen_condi_role_incl(self.temp_dir)
text = ''.join(entries)
self.assertIn("setup a", text)

View File

@ -30,7 +30,7 @@ class TestGenerateDefaultApplications(unittest.TestCase):
shutil.rmtree(self.temp_dir)
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(
[

View File

@ -45,7 +45,7 @@ class TestGenerateDefaultApplicationsUsers(unittest.TestCase):
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.
"""
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([
"python3", str(script_path),
"--roles-dir", str(self.roles_dir),

View File

@ -7,9 +7,9 @@ import yaml
from collections import OrderedDict
# 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):
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'},
'carol': {}
}
users = generate_users.build_users(
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd='pw'
)
# alice should get uid/gid 1001
self.assertEqual(users['alice']['uid'], 1001)
self.assertEqual(users['alice']['gid'], 1001)
self.assertEqual(users['alice']['email'], 'alice@example.com')
self.assertEqual(build['alice']['uid'], 1001)
self.assertEqual(build['alice']['gid'], 1001)
self.assertEqual(build['alice']['email'], 'alice@example.com')
# bob overrides
self.assertEqual(users['bob']['uid'], 2000)
self.assertEqual(users['bob']['gid'], 2000)
self.assertEqual(users['bob']['email'], 'bob@custom.com')
self.assertIn('description', users['bob'])
self.assertEqual(build['bob']['uid'], 2000)
self.assertEqual(build['bob']['gid'], 2000)
self.assertEqual(build['bob']['email'], 'bob@custom.com')
self.assertIn('description', build['bob'])
# carol should get next free id = 1002
self.assertEqual(users['carol']['uid'], 1002)
self.assertEqual(users['carol']['gid'], 1002)
self.assertEqual(build['carol']['uid'], 1002)
self.assertEqual(build['carol']['gid'], 1002)
def test_build_users_default_lookup_password(self):
"""
@ -44,14 +44,14 @@ class TestGenerateUsers(unittest.TestCase):
"""
defs = {'frank': {}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['frank']['password'],
build['frank']['password'],
lookup_template,
"The lookup template string was not correctly applied as the default password"
)
@ -63,14 +63,14 @@ class TestGenerateUsers(unittest.TestCase):
"""
defs = {'eva': {'password': 'custompw'}}
lookup_template = '{{ lookup("password", "/dev/null length=42 chars=ascii_letters,digits") }}'
users = generate_users.build_users(
build = users.build_users(
defs=defs,
primary_domain='example.com',
start_id=1001,
become_pwd=lookup_template
)
self.assertEqual(
users['eva']['password'],
build['eva']['password'],
'custompw',
"The override password was not correctly applied"
)
@ -82,7 +82,7 @@ class TestGenerateUsers(unittest.TestCase):
'u2': {'uid': 1001}
}
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):
# 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},
'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
self.assertEqual(users['a']['gid'], 1500)
self.assertEqual(users['b']['gid'], 1500)
self.assertEqual(build['a']['gid'], 1500)
self.assertEqual(build['b']['gid'], 1500)
def test_build_users_duplicate_username_email(self):
defs = {
@ -102,11 +102,11 @@ class TestGenerateUsers(unittest.TestCase):
}
# second user with same username should raise
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):
od = generate_users.OrderedDict([('a', 1), ('b', {'c': 2})])
result = generate_users.dictify(OrderedDict(od))
od = users.OrderedDict([('a', 1), ('b', {'c': 2})])
result = users.dictify(OrderedDict(od))
self.assertIsInstance(result, dict)
self.assertEqual(result, {'a': 1, 'b': {'c': 2}})
@ -122,13 +122,13 @@ class TestGenerateUsers(unittest.TestCase):
# role2 defines same user x with same value
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as 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)
# now conflict definition
with open(os.path.join(tmp, 'role2/users/main.yml'), 'w') as f:
yaml.safe_dump({'users': {'x': {'email': 'x@b'}}}, f)
with self.assertRaises(ValueError):
generate_users.load_user_defs(tmp)
users.load_user_defs(tmp)
finally:
shutil.rmtree(tmp)

View File

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

View File

View File

@ -7,7 +7,7 @@ import subprocess
import sys
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):
def setUp(self):

0
utils/__init__.py Normal file
View File

View File

View File