computer-playbook/cli/generate_vaulted_credentials.py

145 lines
5.9 KiB
Python

import yaml
import argparse
import secrets
import hashlib
import bcrypt
import subprocess
from pathlib import Path
def prompt(text, default=None):
"""Prompt the user for input, with optional default value."""
prompt_text = f"[?] {text}" + (f" [{default}]" if default else "") + ": "
response = input(prompt_text)
return response.strip() or default
def generate_value(algorithm):
"""Generate a value based on the provided algorithm."""
if algorithm == "random_hex":
return secrets.token_hex(64)
elif algorithm == "sha256":
return hashlib.sha256(secrets.token_bytes(32)).hexdigest()
elif algorithm == "sha1":
return hashlib.sha1(secrets.token_bytes(20)).hexdigest()
elif algorithm == "bcrypt":
password = secrets.token_urlsafe(16).encode()
return bcrypt.hashpw(password, bcrypt.gensalt()).decode()
elif algorithm == "plain":
return secrets.token_urlsafe(32)
else:
return "undefined"
def encrypt_with_vault(value, name, vault_password_file=None, ask_vault_pass=False):
"""Encrypt the given string using Ansible Vault."""
cmd = ["ansible-vault", "encrypt_string", value, f"--name={name}"]
if vault_password_file:
cmd += ["--vault-password-file", vault_password_file]
elif ask_vault_pass:
cmd += ["--ask-vault-pass"]
else:
raise RuntimeError("You must provide --vault-password-file or use --ask-vault-pass.")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"Vault encryption failed:\n{result.stderr}")
return result.stdout.strip()
def load_yaml_file(path):
"""Load a YAML file or return an empty dict if not found."""
if path.exists():
with open(path, "r") as f:
return yaml.safe_load(f) or {}
return {}
def save_yaml_file(path, data):
"""Save a dictionary to a YAML file."""
with open(path, "w") as f:
yaml.dump(data, f, sort_keys=False)
def parse_overrides(pairs):
"""Parse key=value overrides into a dictionary."""
result = {}
for pair in pairs:
if "=" not in pair:
continue
k, v = pair.split("=", 1)
result[k.strip()] = v.strip()
return result
def apply_schema_to_inventory(schema, inventory_data, app_id, overrides, vault_password_file, ask_vault_pass):
"""Merge schema into inventory under applications.{app_id}, encrypting all values."""
inventory_data.setdefault("applications", {})
applications = inventory_data["applications"]
if "applications" not in schema or app_id not in schema["applications"]:
raise KeyError(f"Schema must contain 'applications.{app_id}'")
app_schema = schema["applications"][app_id]
applications.setdefault(app_id, {})
def process_branch(branch, target, path_prefix=""):
for key, meta in branch.items():
full_key_path = f"{path_prefix}.{key}" if path_prefix else key
if isinstance(meta, dict) and all(k in meta for k in ["description", "algorithm", "validation"]):
if key in target:
overwrite = prompt(f"Key '{full_key_path}' already exists. Overwrite?", "n").lower() == "y"
if not overwrite:
continue
plain_value = overrides.get(full_key_path, generate_value(meta["algorithm"]))
vaulted_value = encrypt_with_vault(plain_value, key, vault_password_file, ask_vault_pass)
target[key] = yaml.load(vaulted_value, Loader=yaml.SafeLoader)
elif isinstance(meta, dict):
target.setdefault(key, {})
process_branch(meta, target[key], full_key_path)
else:
target[key] = meta
process_branch(app_schema, applications[app_id])
return inventory_data
def main():
parser = argparse.ArgumentParser(description="Generate Vault-encrypted credentials from schema and write to inventory.")
parser.add_argument("--role-path", help="Path to the Ansible role")
parser.add_argument("--inventory-file", help="Path to the inventory file to update")
parser.add_argument("--application-id", help="Application ID to process (e.g. bigbluebutton)")
parser.add_argument("--vault-password-file", help="Path to Ansible Vault password file")
parser.add_argument("--ask-vault-pass", action="store_true", help="Prompt for vault password")
parser.add_argument("--set", nargs="*", default=[], help="Override values as key=value")
args = parser.parse_args()
# Prompt for missing values
role_path = Path(args.role_path or prompt("Path to Ansible role", "./roles/docker-bigbluebutton"))
inventory_file = Path(args.inventory_file or prompt("Path to inventory file", "./host_vars/localhost.yml"))
app_id = args.application_id or prompt("Application ID", "bigbluebutton")
if not args.vault_password_file and not args.ask_vault_pass:
print("[?] No Vault password method provided.")
print(" 1) Provide path to --vault-password-file")
print(" 2) Use interactive prompt (--ask-vault-pass)")
choice = prompt("Select method", "1")
if choice == "1":
args.vault_password_file = prompt("Vault password file", "~/.vault_pass.txt").replace("~", str(Path.home()))
else:
args.ask_vault_pass = True
# Load files
schema_path = role_path / "meta" / "schema.yml"
schema_data = load_yaml_file(schema_path)
inventory_data = load_yaml_file(inventory_file)
overrides = parse_overrides(args.set)
# Process and save
updated = apply_schema_to_inventory(
schema=schema_data,
inventory_data=inventory_data,
app_id=app_id,
overrides=overrides,
vault_password_file=args.vault_password_file,
ask_vault_pass=args.ask_vault_pass
)
save_yaml_file(inventory_file, updated)
print(f"\n✅ Inventory file updated at: {inventory_file}")
if __name__ == "__main__":
main()