From 3b32c9479f9bf976f1fa5914f2677a04d7f81d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Sat, 7 Feb 2026 12:54:42 +0100 Subject: [PATCH] create-host: add approle removal and secrets detection - Remove host entries from terraform/vault/approle.tf on --remove - Detect and warn about secrets in terraform/vault/secrets.tf - Include vault kv delete commands in removal instructions - Update check_entries_exist to return approle status Co-Authored-By: Claude Opus 4.5 --- scripts/create-host/create_host.py | 56 ++++++++++++++++++--- scripts/create-host/manipulators.py | 75 +++++++++++++++++++++++++++-- 2 files changed, 121 insertions(+), 10 deletions(-) diff --git a/scripts/create-host/create_host.py b/scripts/create-host/create_host.py index e1d23d4..941fdda 100644 --- a/scripts/create-host/create_host.py +++ b/scripts/create-host/create_host.py @@ -18,6 +18,8 @@ from manipulators import ( remove_from_flake_nix, remove_from_terraform_vms, remove_from_vault_terraform, + remove_from_approle_tf, + find_host_secrets, check_entries_exist, ) from models import HostConfig @@ -255,7 +257,10 @@ def handle_remove( sys.exit(1) # Check what entries exist - flake_exists, terraform_exists, vault_exists = check_entries_exist(hostname, repo_root) + flake_exists, terraform_exists, vault_exists, approle_exists = check_entries_exist(hostname, repo_root) + + # Check for secrets in secrets.tf + host_secrets = find_host_secrets(hostname, repo_root) # Collect all files in the host directory recursively files_in_host_dir = sorted([f for f in host_dir.rglob("*") if f.is_file()]) @@ -294,6 +299,21 @@ def handle_remove( else: console.print(f" • terraform/vault/hosts-generated.tf [dim](not found)[/dim]") + if approle_exists: + console.print(f' • terraform/vault/approle.tf (host_policies["{hostname}"])') + else: + console.print(f" • terraform/vault/approle.tf [dim](not found)[/dim]") + + # Warn about secrets in secrets.tf + if host_secrets: + console.print(f"\n[yellow]⚠️ Warning: Found {len(host_secrets)} secret(s) in terraform/vault/secrets.tf:[/yellow]") + for secret_path in host_secrets: + console.print(f' • "{secret_path}"') + console.print(f"\n [yellow]These will NOT be removed automatically.[/yellow]") + console.print(f" After removal, manually edit secrets.tf and run:") + for secret_path in host_secrets: + console.print(f" [white]vault kv delete secret/{secret_path}[/white]") + # Warn about secrets directory if secrets_exist: console.print(f"\n[yellow]⚠️ Warning: secrets/{hostname}/ directory exists and will NOT be deleted[/yellow]") @@ -323,6 +343,13 @@ def handle_remove( else: console.print("[yellow]⚠[/yellow] Could not remove from terraform/vault/hosts-generated.tf") + # Remove from terraform/vault/approle.tf + if approle_exists: + if remove_from_approle_tf(hostname, repo_root): + console.print("[green]✓[/green] Removed from terraform/vault/approle.tf") + else: + console.print("[yellow]⚠[/yellow] Could not remove from terraform/vault/approle.tf") + # Remove from terraform/vms.tf if terraform_exists: if remove_from_terraform_vms(hostname, repo_root): @@ -345,19 +372,34 @@ def handle_remove( console.print(f"\n[bold green]✓ Host {hostname} removed successfully![/bold green]\n") # Display next steps - display_removal_next_steps(hostname, vault_exists) + display_removal_next_steps(hostname, vault_exists, approle_exists, host_secrets) -def display_removal_next_steps(hostname: str, had_vault: bool) -> None: +def display_removal_next_steps(hostname: str, had_vault: bool, had_approle: bool, host_secrets: list) -> None: """Display next steps after successful removal.""" - vault_file = " terraform/vault/hosts-generated.tf" if had_vault else "" - vault_apply = "" + vault_files = "" if had_vault: + vault_files += " terraform/vault/hosts-generated.tf" + if had_approle: + vault_files += " terraform/vault/approle.tf" + + vault_apply = "" + if had_vault or had_approle: vault_apply = f""" 3. Apply Vault changes: [white]cd terraform/vault && tofu apply[/white] """ + secrets_cleanup = "" + if host_secrets: + secrets_cleanup = f""" +5. Clean up secrets (manual): + Edit terraform/vault/secrets.tf to remove entries for {hostname} + Then delete from Vault:""" + for secret_path in host_secrets: + secrets_cleanup += f"\n [white]vault kv delete secret/{secret_path}[/white]" + secrets_cleanup += "\n" + next_steps = f"""[bold cyan]Next Steps:[/bold cyan] 1. Review changes: @@ -367,9 +409,9 @@ def display_removal_next_steps(hostname: str, had_vault: bool) -> None: [white]cd terraform && tofu destroy -target='proxmox_vm_qemu.vm["{hostname}"]'[/white] {vault_apply} 4. Commit changes: - [white]git add -u hosts/{hostname} flake.nix terraform/vms.tf{vault_file} + [white]git add -u hosts/{hostname} flake.nix terraform/vms.tf{vault_files} git commit -m "hosts: remove {hostname}"[/white] -""" +{secrets_cleanup}""" console.print(Panel(next_steps, border_style="cyan")) diff --git a/scripts/create-host/manipulators.py b/scripts/create-host/manipulators.py index b3eafaa..28f7671 100644 --- a/scripts/create-host/manipulators.py +++ b/scripts/create-host/manipulators.py @@ -101,7 +101,68 @@ def remove_from_vault_terraform(hostname: str, repo_root: Path) -> bool: return True -def check_entries_exist(hostname: str, repo_root: Path) -> Tuple[bool, bool, bool]: +def remove_from_approle_tf(hostname: str, repo_root: Path) -> bool: + """ + Remove host entry from terraform/vault/approle.tf locals.host_policies. + + Args: + hostname: Hostname to remove + repo_root: Path to repository root + + Returns: + True if found and removed, False if not found + """ + approle_path = repo_root / "terraform" / "vault" / "approle.tf" + + if not approle_path.exists(): + return False + + content = approle_path.read_text() + + # Check if hostname exists in host_policies + hostname_pattern = rf'^\s+"{re.escape(hostname)}" = \{{' + if not re.search(hostname_pattern, content, re.MULTILINE): + return False + + # Match the entire block from "hostname" = { to closing } + # The block contains paths = [ ... ] and possibly extra_policies = [...] + replace_pattern = rf'\n?\s+"{re.escape(hostname)}" = \{{[^}}]*\}}\n?' + new_content, count = re.subn(replace_pattern, "\n", content, flags=re.DOTALL) + + if count == 0: + return False + + approle_path.write_text(new_content) + return True + + +def find_host_secrets(hostname: str, repo_root: Path) -> list: + """ + Find secrets in terraform/vault/secrets.tf that belong to a host. + + Args: + hostname: Hostname to search for + repo_root: Path to repository root + + Returns: + List of secret paths found (e.g., ["hosts/hostname/test-service"]) + """ + secrets_path = repo_root / "terraform" / "vault" / "secrets.tf" + + if not secrets_path.exists(): + return [] + + content = secrets_path.read_text() + + # Find all secret paths matching hosts/{hostname}/ + pattern = rf'"(hosts/{re.escape(hostname)}/[^"]+)"' + matches = re.findall(pattern, content) + + # Return unique paths, preserving order + return list(dict.fromkeys(matches)) + + +def check_entries_exist(hostname: str, repo_root: Path) -> Tuple[bool, bool, bool, bool]: """ Check which entries exist for a hostname. @@ -110,7 +171,7 @@ def check_entries_exist(hostname: str, repo_root: Path) -> Tuple[bool, bool, boo repo_root: Path to repository root Returns: - Tuple of (flake_exists, terraform_vms_exists, vault_exists) + Tuple of (flake_exists, terraform_vms_exists, vault_generated_exists, approle_exists) """ # Check flake.nix flake_path = repo_root / "flake.nix" @@ -131,7 +192,15 @@ def check_entries_exist(hostname: str, repo_root: Path) -> Tuple[bool, bool, boo vault_content = vault_tf_path.read_text() vault_exists = f'"{hostname}"' in vault_content - return (flake_exists, terraform_exists, vault_exists) + # Check terraform/vault/approle.tf + approle_path = repo_root / "terraform" / "vault" / "approle.tf" + approle_exists = False + if approle_path.exists(): + approle_content = approle_path.read_text() + approle_pattern = rf'^\s+"{re.escape(hostname)}" = \{{' + approle_exists = bool(re.search(approle_pattern, approle_content, re.MULTILINE)) + + return (flake_exists, terraform_exists, vault_exists, approle_exists) def update_flake_nix(config: HostConfig, repo_root: Path, force: bool = False) -> None: