Restructured CLI logic

This commit is contained in:
2025-07-10 21:26:44 +02:00
parent 8457325b5c
commit c160c58a5c
44 changed files with 97 additions and 155 deletions

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

115
cli/create/credentials.py Normal file
View File

@@ -0,0 +1,115 @@
import argparse
import subprocess
import sys
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 yaml.dumper import SafeDumper
def ask_for_confirmation(key: str) -> bool:
"""Prompt the user for confirmation to overwrite an existing value."""
confirmation = input(
f"Are you sure you want to overwrite the value for '{key}'? (y/n): "
).strip().lower()
return confirmation == 'y'
def main():
parser = argparse.ArgumentParser(
description="Selectively vault credentials + become-password in your inventory."
)
parser.add_argument(
"--role-path", required=True, help="Path to your role"
)
parser.add_argument(
"--inventory-file", required=True, help="Host vars file to update"
)
parser.add_argument(
"--vault-password-file", required=True, help="Vault password file"
)
parser.add_argument(
"--set", nargs="*", default=[], help="Override values key.subkey=VALUE"
)
parser.add_argument(
"-f", "--force", action="store_true",
help="Force overwrite without confirmation"
)
args = parser.parse_args()
# Parse overrides
overrides = {
k.strip(): v.strip()
for pair in args.set for k, v in [pair.split("=", 1)]
}
# Initialize inventory manager
manager = InventoryManager(
role_path=Path(args.role_path),
inventory_path=Path(args.inventory_file),
vault_pw=args.vault_password_file,
overrides=overrides
)
# Load existing credentials to preserve
existing_apps = manager.inventory.get("applications", {})
existing_creds = {}
if manager.app_id in existing_apps:
existing_creds = existing_apps[manager.app_id].get("credentials", {}).copy()
# Apply schema (may generate defaults)
updated_inventory = manager.apply_schema()
# Restore existing database_password if present
apps = updated_inventory.setdefault("applications", {})
app_block = apps.setdefault(manager.app_id, {})
creds = app_block.setdefault("credentials", {})
if "database_password" in existing_creds:
creds["database_password"] = existing_creds["database_password"]
# Store original plaintext values
original_plain = {key: str(val) for key, val in creds.items()}
for key, raw_val in list(creds.items()):
# Skip if already vaulted
if isinstance(raw_val, VaultScalar) or str(raw_val).lstrip().startswith("$ANSIBLE_VAULT"):
continue
# Determine plaintext
plain = original_plain.get(key, "")
if key in overrides and (args.force or ask_for_confirmation(key)):
plain = overrides[key]
# Encrypt the plaintext
encrypted = manager.vault_handler.encrypt_string(plain, key)
lines = encrypted.splitlines()
indent = len(lines[1]) - len(lines[1].lstrip())
body = "\n".join(line[indent:] for line in lines[1:])
creds[key] = VaultScalar(body)
# Vault top-level become password if present
if "ansible_become_password" in updated_inventory:
val = str(updated_inventory["ansible_become_password"])
if val.lstrip().startswith("$ANSIBLE_VAULT"):
updated_inventory["ansible_become_password"] = VaultScalar(val)
else:
snippet = manager.vault_handler.encrypt_string(
val, "ansible_become_password"
)
lines = snippet.splitlines()
indent = len(lines[1]) - len(lines[1].lstrip())
body = "\n".join(line[indent:] for line in lines[1:])
updated_inventory["ansible_become_password"] = VaultScalar(body)
# Write back to file
with open(args.inventory_file, "w", encoding="utf-8") as f:
yaml.dump(updated_inventory, f, sort_keys=False, Dumper=SafeDumper)
print(f"✅ Inventory selectively vaulted → {args.inventory_file}")
if __name__ == "__main__":
main()

163
cli/create/role.py Normal file
View File

@@ -0,0 +1,163 @@
#!/usr/bin/env python3
import argparse
import os
import shutil
import sys
import ipaddress
import difflib
from jinja2 import Environment, FileSystemLoader
from ruamel.yaml import YAML
# Paths to the group-vars files
PORTS_FILE = './group_vars/all/09_ports.yml'
NETWORKS_FILE = './group_vars/all/10_networks.yml'
ROLE_TEMPLATE_DIR = './templates/roles/web-app'
ROLES_DIR = './roles'
yaml = YAML()
yaml.preserve_quotes = True
def load_yaml_with_comments(path):
with open(path) as f:
return yaml.load(f)
def dump_yaml_with_comments(data, path):
with open(path, 'w') as f:
yaml.dump(data, f)
def get_next_network(networks_dict, prefixlen):
"""Select the next contiguous subnet, based on the highest existing subnet + one network offset."""
nets = []
local = networks_dict['defaults_networks']['local']
for name, info in local.items():
# info is a dict with 'subnet' key
net = ipaddress.ip_network(info['subnet'])
if net.prefixlen == prefixlen:
nets.append(net)
if not nets:
raise RuntimeError(f"No existing /{prefixlen} subnets to base allocation on.")
nets.sort(key=lambda n: int(n.network_address))
last = nets[-1]
offset = last.num_addresses
next_net = ipaddress.ip_network((int(last.network_address) + offset, prefixlen))
return next_net
def get_next_port(ports_dict, category):
"""Assign the next port by taking the max existing plus one."""
loc = ports_dict['ports']['localhost'][category]
existing = [int(v) for v in loc.values()]
return (max(existing) + 1) if existing else 1
def prompt_conflict(dst_file):
print(f"Conflict detected: {dst_file}")
print("[1] overwrite, [2] skip, [3] merge")
choice = None
while choice not in ('1', '2', '3'):
choice = input("Enter 1, 2, or 3: ").strip()
return choice
def render_templates(src_dir, dst_dir, context):
env = Environment(loader=FileSystemLoader(src_dir), keep_trailing_newline=True, autoescape=False)
env.filters['bool'] = lambda x: bool(x)
for root, _, files in os.walk(src_dir):
rel = os.path.relpath(root, src_dir)
target = os.path.join(dst_dir, rel)
os.makedirs(target, exist_ok=True)
for fn in files:
tpl = env.get_template(os.path.join(rel, fn))
rendered = tpl.render(**context)
out = fn[:-3] if fn.endswith('.j2') else fn
dst_file = os.path.join(target, out)
if os.path.exists(dst_file):
choice = prompt_conflict(dst_file)
if choice == '2':
print(f"Skipping {dst_file}")
continue
if choice == '3':
with open(dst_file) as f_old:
old_lines = f_old.readlines()
new_lines = rendered.splitlines(keepends=True)
additions = [l for l in new_lines if l not in old_lines]
if additions:
with open(dst_file, 'a') as f:
f.writelines(additions)
print(f"Merged {len(additions)} lines into {dst_file}")
else:
print(f"No new lines to merge into {dst_file}")
continue
# overwrite
print(f"Overwriting {dst_file}")
with open(dst_file, 'w') as f:
f.write(rendered)
else:
# create new file
with open(dst_file, 'w') as f:
f.write(rendered)
def main():
# Load dynamic port categories
ports_data = load_yaml_with_comments(PORTS_FILE)
categories = list(ports_data['ports']['localhost'].keys())
parser = argparse.ArgumentParser(
description="Create or update a Docker Ansible role, and globally assign network and ports with comments preserved"
)
parser.add_argument('-a', '--application-id', required=True, help="Unique application ID")
parser.add_argument('-n', '--network', choices=['24', '28'], required=True, help="Network prefix length (/24 or /28)")
parser.add_argument('-p', '--ports', nargs='+', choices=categories, required=True, help=f"Port categories to assign (allowed: {', '.join(categories)})")
args = parser.parse_args()
app = args.application_id
role = f"web-app-{app}"
role_dir = os.path.join(ROLES_DIR, role)
if os.path.exists(role_dir):
if input(f"Role {role} exists. Continue? [y/N]: ").strip().lower() != 'y':
print("Aborting.")
sys.exit(1)
else:
os.makedirs(role_dir)
# 1) Render all templates with conflict handling
render_templates(ROLE_TEMPLATE_DIR, role_dir, {'application_id': app, 'role_name': role, 'database_type': 0})
print(f"→ Templates applied to {role_dir}")
# 2) Update global networks file, preserving comments
networks = load_yaml_with_comments(NETWORKS_FILE)
prefix = int(args.network)
new_net = get_next_network(networks, prefix)
networks['defaults_networks']['local'][app] = {'subnet': str(new_net)}
shutil.copy(NETWORKS_FILE, NETWORKS_FILE + '.bak')
dump_yaml_with_comments(networks, NETWORKS_FILE)
print(f"→ Assigned network {new_net} in {NETWORKS_FILE}")
# 3) Update global ports file, preserving comments
ports_data = load_yaml_with_comments(PORTS_FILE)
assigned = {}
for cat in args.ports:
loc = ports_data['ports']['localhost'].setdefault(cat, {})
if app in loc:
print(f"→ Existing port for {cat} and {app}: {loc[app]}, skipping.")
else:
pnum = get_next_port(ports_data, cat)
loc[app] = pnum
assigned[cat] = pnum
if assigned:
shutil.copy(PORTS_FILE, PORTS_FILE + '.bak')
dump_yaml_with_comments(ports_data, PORTS_FILE)
print(f"→ Assigned ports {assigned} in {PORTS_FILE}")
else:
print("→ No new ports assigned.")
if __name__ == '__main__':
main()