mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-08-29 15:06:26 +02:00
Restructured CLI logic
This commit is contained in:
@@ -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
|
||||
|
||||
|
@@ -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
0
cli/fix/__init__.py
Normal file
47
cli/fix/ini_py.py
Normal file
47
cli/fix/ini_py.py
Normal 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
0
cli/generate/__init__.py
Normal 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:
|
0
cli/generate/defaults/__init__.py
Normal file
0
cli/generate/defaults/__init__.py
Normal 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
0
cli/meta/__init__.py
Normal 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()
|
@@ -1,50 +0,0 @@
|
||||
import subprocess
|
||||
from typing import Any, Dict
|
||||
|
||||
from yaml.loader import SafeLoader
|
||||
from yaml.dumper import SafeDumper
|
||||
|
||||
class VaultScalar(str):
|
||||
"""A subclass of str to represent vault-encrypted strings."""
|
||||
pass
|
||||
|
||||
def _vault_constructor(loader, node):
|
||||
"""Custom constructor to handle !vault tag as plain text."""
|
||||
return node.value
|
||||
|
||||
def _vault_representer(dumper, data):
|
||||
"""Custom representer to dump VaultScalar as literal blocks."""
|
||||
return dumper.represent_scalar('!vault', data, style='|')
|
||||
|
||||
SafeLoader.add_constructor('!vault', _vault_constructor)
|
||||
SafeDumper.add_representer(VaultScalar, _vault_representer)
|
||||
|
||||
class VaultHandler:
|
||||
def __init__(self, vault_password_file: str):
|
||||
self.vault_password_file = vault_password_file
|
||||
|
||||
def encrypt_string(self, value: str, name: str) -> str:
|
||||
"""Encrypt a string using ansible-vault."""
|
||||
cmd = [
|
||||
"ansible-vault", "encrypt_string",
|
||||
value, f"--name={name}",
|
||||
"--vault-password-file", self.vault_password_file
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ansible-vault encrypt_string failed:\n{proc.stderr}")
|
||||
return proc.stdout
|
||||
|
||||
def encrypt_leaves(self, branch: Dict[str, Any], vault_pw: str):
|
||||
"""Recursively encrypt all leaves (plain text values) under the credentials section."""
|
||||
for key, value in branch.items():
|
||||
if isinstance(value, dict):
|
||||
self.encrypt_leaves(value, vault_pw) # Recurse into nested dictionaries
|
||||
else:
|
||||
# Skip if already vaulted (i.e., starts with $ANSIBLE_VAULT)
|
||||
if isinstance(value, str) and not value.lstrip().startswith("$ANSIBLE_VAULT"):
|
||||
snippet = self.encrypt_string(value, key)
|
||||
lines = snippet.splitlines()
|
||||
indent = len(lines[1]) - len(lines[1].lstrip())
|
||||
body = "\n".join(line[indent:] for line in lines[1:])
|
||||
branch[key] = VaultScalar(body) # Store encrypted value as VaultScalar
|
@@ -1,23 +0,0 @@
|
||||
import yaml
|
||||
from yaml.loader import SafeLoader
|
||||
from typing import Any, Dict
|
||||
from utils.handler.vault import VaultScalar
|
||||
|
||||
class YamlHandler:
|
||||
@staticmethod
|
||||
def load_yaml(path) -> Dict:
|
||||
"""Load the YAML file and wrap existing !vault entries."""
|
||||
text = path.read_text()
|
||||
data = yaml.load(text, Loader=SafeLoader) or {}
|
||||
return YamlHandler.wrap_existing_vaults(data)
|
||||
|
||||
@staticmethod
|
||||
def wrap_existing_vaults(node: Any) -> Any:
|
||||
"""Recursively wrap any str that begins with '$ANSIBLE_VAULT' in a VaultScalar so it dumps as a literal block."""
|
||||
if isinstance(node, dict):
|
||||
return {k: YamlHandler.wrap_existing_vaults(v) for k, v in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [YamlHandler.wrap_existing_vaults(v) for v in node]
|
||||
if isinstance(node, str) and node.lstrip().startswith("$ANSIBLE_VAULT"):
|
||||
return VaultScalar(node)
|
||||
return node
|
@@ -1,165 +0,0 @@
|
||||
import secrets
|
||||
import hashlib
|
||||
import bcrypt
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
from utils.handler.yaml import YamlHandler
|
||||
from utils.handler.vault import VaultHandler, VaultScalar
|
||||
import string
|
||||
import sys
|
||||
import base64
|
||||
|
||||
class InventoryManager:
|
||||
def __init__(self, role_path: Path, inventory_path: Path, vault_pw: str, overrides: Dict[str, str]):
|
||||
"""Initialize the Inventory Manager."""
|
||||
self.role_path = role_path
|
||||
self.inventory_path = inventory_path
|
||||
self.vault_pw = vault_pw
|
||||
self.overrides = overrides
|
||||
self.inventory = YamlHandler.load_yaml(inventory_path)
|
||||
self.schema = YamlHandler.load_yaml(role_path / "schema" / "main.yml")
|
||||
self.app_id = self.load_application_id(role_path)
|
||||
|
||||
self.vault_handler = VaultHandler(vault_pw)
|
||||
|
||||
def load_application_id(self, role_path: Path) -> str:
|
||||
"""Load the application ID from the role's vars/main.yml file."""
|
||||
vars_file = role_path / "vars" / "main.yml"
|
||||
data = YamlHandler.load_yaml(vars_file)
|
||||
app_id = data.get("application_id")
|
||||
if not app_id:
|
||||
print(f"ERROR: 'application_id' missing in {vars_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return app_id
|
||||
|
||||
def apply_schema(self) -> Dict:
|
||||
"""Apply the schema and return the updated inventory."""
|
||||
apps = self.inventory.setdefault("applications", {})
|
||||
target = apps.setdefault(self.app_id, {})
|
||||
|
||||
# Load the data from vars/main.yml
|
||||
vars_file = self.role_path / "config" / "main.yml"
|
||||
data = YamlHandler.load_yaml(vars_file)
|
||||
|
||||
# Check if 'central-database' is enabled in the features section of data
|
||||
if "features" in data:
|
||||
if "central_database" in data["features"] and \
|
||||
data["features"]["central_database"]:
|
||||
# Add 'central_database' value (password) to credentials
|
||||
target.setdefault("credentials", {})["database_password"] = self.generate_value("alphanumeric")
|
||||
if "oauth2" in data["features"] and \
|
||||
data["features"]["oauth2"]:
|
||||
target.setdefault("credentials", {})["oauth2_proxy_cookie_secret"] = self.generate_value("random_hex_16")
|
||||
|
||||
# Apply recursion only for the `credentials` section
|
||||
self.recurse_credentials(self.schema, target)
|
||||
return self.inventory
|
||||
|
||||
def recurse_credentials(self, branch: dict, dest: dict, prefix: str = ""):
|
||||
"""Recursively process only the 'credentials' section and generate values."""
|
||||
for key, meta in branch.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
# Only process 'credentials' section for encryption
|
||||
if prefix == "credentials" and isinstance(meta, dict) and all(k in meta for k in ("description", "algorithm", "validation")):
|
||||
alg = meta["algorithm"]
|
||||
if alg == "plain":
|
||||
# Must be supplied via --set
|
||||
if full_key not in self.overrides:
|
||||
print(f"ERROR: Plain algorithm for '{full_key}' requires override via --set {full_key}=<value>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
plain = self.overrides[full_key]
|
||||
else:
|
||||
plain = self.overrides.get(full_key, self.generate_value(alg))
|
||||
|
||||
# Check if the value is already vaulted or if it's a dictionary
|
||||
existing_value = dest.get(key)
|
||||
|
||||
# If existing_value is a dictionary, print a warning and skip encryption
|
||||
if isinstance(existing_value, dict):
|
||||
print(f"Skipping encryption for '{key}', as it is a dictionary.")
|
||||
continue
|
||||
|
||||
# Check if the value is a VaultScalar and already vaulted
|
||||
if existing_value and isinstance(existing_value, VaultScalar):
|
||||
print(f"Skipping encryption for '{key}', as it is already vaulted.")
|
||||
continue
|
||||
|
||||
# Encrypt only if it's not already vaulted
|
||||
snippet = self.vault_handler.encrypt_string(plain, key)
|
||||
lines = snippet.splitlines()
|
||||
indent = len(lines[1]) - len(lines[1].lstrip())
|
||||
body = "\n".join(line[indent:] for line in lines[1:])
|
||||
dest[key] = VaultScalar(body)
|
||||
|
||||
elif isinstance(meta, dict):
|
||||
sub = dest.setdefault(key, {})
|
||||
self.recurse_credentials(meta, sub, full_key)
|
||||
else:
|
||||
dest[key] = meta
|
||||
|
||||
|
||||
def generate_secure_alphanumeric(self, length: int) -> str:
|
||||
"""Generate a cryptographically secure random alphanumeric string of the given length."""
|
||||
characters = string.ascii_letters + string.digits # a-zA-Z0-9
|
||||
return ''.join(secrets.choice(characters) for _ in range(length))
|
||||
|
||||
def generate_value(self, algorithm: str) -> str:
|
||||
"""
|
||||
Generate a random secret value according to the specified algorithm.
|
||||
|
||||
Supported algorithms:
|
||||
• "random_hex"
|
||||
– Returns a 64-byte (512-bit) secure random string, encoded as 128 hexadecimal characters.
|
||||
– Use when you need maximum entropy in a hex-only format.
|
||||
|
||||
• "sha256"
|
||||
– Generates 32 random bytes, hashes them with SHA-256, and returns a 64-character hex digest.
|
||||
– Good for when you want a fixed-length (256-bit) hash output.
|
||||
|
||||
• "sha1"
|
||||
– Generates 20 random bytes, hashes them with SHA-1, and returns a 40-character hex digest.
|
||||
– Only use in legacy contexts; SHA-1 is considered weaker than SHA-256.
|
||||
|
||||
• "bcrypt"
|
||||
– Creates a random 16-byte URL-safe password, then applies a bcrypt hash.
|
||||
– Suitable for storing user-style passwords where bcrypt verification is needed.
|
||||
|
||||
• "alphanumeric"
|
||||
– Produces a 64-character string drawn from [A–Z, a–z, 0–9].
|
||||
– Offers ≈380 bits of entropy; human-friendly charset.
|
||||
|
||||
• "base64_prefixed_32"
|
||||
– Generates 32 random bytes, encodes them in Base64, and prefixes the result with "base64:".
|
||||
– Useful when downstream systems expect a Base64 format.
|
||||
|
||||
• "random_hex_16"
|
||||
– Returns 16 random bytes (128 bits) encoded as 32 hexadecimal characters.
|
||||
– Handy for shorter tokens or salts.
|
||||
|
||||
Returns:
|
||||
A securely generated string according to the chosen algorithm.
|
||||
"""
|
||||
if algorithm == "random_hex":
|
||||
return secrets.token_hex(64)
|
||||
|
||||
if algorithm == "sha256":
|
||||
return hashlib.sha256(secrets.token_bytes(32)).hexdigest()
|
||||
if algorithm == "sha1":
|
||||
return hashlib.sha1(secrets.token_bytes(20)).hexdigest()
|
||||
if algorithm == "bcrypt":
|
||||
# Generate a random password and hash it with bcrypt
|
||||
pw = secrets.token_urlsafe(16).encode()
|
||||
raw_hash = bcrypt.hashpw(pw, bcrypt.gensalt()).decode()
|
||||
# Replace every '$' with a random lowercase alphanumeric character
|
||||
alnum = string.digits + string.ascii_lowercase
|
||||
escaped = "".join(secrets.choice(alnum) if ch == '$' else ch for ch in raw_hash)
|
||||
return escaped
|
||||
if algorithm == "alphanumeric":
|
||||
return self.generate_secure_alphanumeric(64)
|
||||
if algorithm == "base64_prefixed_32":
|
||||
return "base64:" + base64.b64encode(secrets.token_bytes(32)).decode()
|
||||
if algorithm == "random_hex_16":
|
||||
# 16 Bytes → 32 Hex-Characters
|
||||
return secrets.token_hex(16)
|
||||
return "undefined"
|
0
cli/validate/__init__.py
Normal file
0
cli/validate/__init__.py
Normal file
Reference in New Issue
Block a user