From 86249c466b733e3290e4c1084ee1efd93879da32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 12:11:41 +0100 Subject: [PATCH] create-host: add delete feature --- scripts/create-host/create_host.py | 179 +++++++++++++++++++++++++++- scripts/create-host/manipulators.py | 128 ++++++++++++++++++++ 2 files changed, 305 insertions(+), 2 deletions(-) diff --git a/scripts/create-host/create_host.py b/scripts/create-host/create_host.py index a86f86e..e1d23d4 100644 --- a/scripts/create-host/create_host.py +++ b/scripts/create-host/create_host.py @@ -1,5 +1,6 @@ """CLI tool for generating NixOS host configurations.""" +import shutil import sys from pathlib import Path from typing import Optional @@ -10,7 +11,15 @@ from rich.panel import Panel from rich.table import Table from generators import generate_host_files, generate_vault_terraform -from manipulators import update_flake_nix, update_terraform_vms, add_wrapped_token_to_vm +from manipulators import ( + update_flake_nix, + update_terraform_vms, + add_wrapped_token_to_vm, + remove_from_flake_nix, + remove_from_terraform_vms, + remove_from_vault_terraform, + check_entries_exist, +) from models import HostConfig from vault_helper import generate_wrapped_token from validators import ( @@ -46,9 +55,10 @@ def main( memory: int = typer.Option(2048, "--memory", help="Memory in MB"), disk: str = typer.Option("20G", "--disk", help="Disk size (e.g., 20G, 50G, 100G)"), dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without creating files"), - force: bool = typer.Option(False, "--force", help="Overwrite existing host configuration"), + force: bool = typer.Option(False, "--force", help="Overwrite existing host configuration / skip confirmation for removal"), skip_vault: bool = typer.Option(False, "--skip-vault", help="Skip Vault configuration and token generation"), regenerate_token: bool = typer.Option(False, "--regenerate-token", help="Only regenerate Vault wrapped token (no other changes)"), + remove: bool = typer.Option(False, "--remove", help="Remove host configuration and terraform entries"), ) -> None: """ Create a new NixOS host configuration. @@ -64,6 +74,11 @@ def main( # Get repository root repo_root = get_repo_root() + # Handle removal mode + if remove: + handle_remove(hostname, repo_root, dry_run, force, ip, cpu, memory, disk, skip_vault, regenerate_token) + return + # Handle token regeneration mode if regenerate_token: # Validate that incompatible options aren't used @@ -198,6 +213,166 @@ def main( sys.exit(1) +def handle_remove( + hostname: str, + repo_root: Path, + dry_run: bool, + force: bool, + ip: Optional[str], + cpu: int, + memory: int, + disk: str, + skip_vault: bool, + regenerate_token: bool, +) -> None: + """Handle the --remove workflow.""" + # Validate --remove isn't used with create options + incompatible_options = [] + if ip: + incompatible_options.append("--ip") + if cpu != 2: + incompatible_options.append("--cpu") + if memory != 2048: + incompatible_options.append("--memory") + if disk != "20G": + incompatible_options.append("--disk") + if skip_vault: + incompatible_options.append("--skip-vault") + if regenerate_token: + incompatible_options.append("--regenerate-token") + + if incompatible_options: + console.print( + f"[bold red]Error:[/bold red] --remove cannot be used with: {', '.join(incompatible_options)}\n" + ) + sys.exit(1) + + # Validate hostname exists (host directory must exist) + host_dir = repo_root / "hosts" / hostname + if not host_dir.exists(): + console.print(f"[bold red]Error:[/bold red] Host {hostname} does not exist") + console.print(f"Host directory not found: {host_dir}") + sys.exit(1) + + # Check what entries exist + flake_exists, terraform_exists, vault_exists = check_entries_exist(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()]) + + # Check for secrets directory + secrets_dir = repo_root / "secrets" / hostname + secrets_exist = secrets_dir.exists() + + # Display summary + if dry_run: + console.print("\n[yellow][DRY RUN - No changes will be made][/yellow]\n") + + console.print(f"\n[bold blue]Removing host: {hostname}[/bold blue]\n") + + # Show host directory contents + console.print("[bold]Directory to be deleted (and all contents):[/bold]") + console.print(f" • hosts/{hostname}/") + for f in files_in_host_dir: + rel_path = f.relative_to(host_dir) + console.print(f" - {rel_path}") + + # Show entries to be removed + console.print("\n[bold]Entries to be removed:[/bold]") + if flake_exists: + console.print(f" • flake.nix (nixosConfigurations.{hostname})") + else: + console.print(f" • flake.nix [dim](not found)[/dim]") + + if terraform_exists: + console.print(f' • terraform/vms.tf (locals.vms["{hostname}"])') + else: + console.print(f" • terraform/vms.tf [dim](not found)[/dim]") + + if vault_exists: + console.print(f' • terraform/vault/hosts-generated.tf (generated_host_policies["{hostname}"])') + else: + console.print(f" • terraform/vault/hosts-generated.tf [dim](not found)[/dim]") + + # Warn about secrets directory + if secrets_exist: + console.print(f"\n[yellow]⚠️ Warning: secrets/{hostname}/ directory exists and will NOT be deleted[/yellow]") + console.print(f" Manually remove if no longer needed: [white]rm -rf secrets/{hostname}/[/white]") + console.print(f" Also update .sops.yaml to remove the host's age key") + + # Exit if dry run + if dry_run: + console.print("\n[yellow][DRY RUN - No changes made][/yellow]\n") + return + + # Prompt for confirmation unless --force + if not force: + console.print("") + confirm = typer.confirm("Proceed with removal?", default=False) + if not confirm: + console.print("\n[yellow]Removal cancelled[/yellow]\n") + sys.exit(0) + + # Perform removal + console.print("\n[bold blue]Removing host configuration...[/bold blue]") + + # Remove from terraform/vault/hosts-generated.tf + if vault_exists: + if remove_from_vault_terraform(hostname, repo_root): + console.print("[green]✓[/green] Removed from terraform/vault/hosts-generated.tf") + else: + console.print("[yellow]⚠[/yellow] Could not remove from terraform/vault/hosts-generated.tf") + + # Remove from terraform/vms.tf + if terraform_exists: + if remove_from_terraform_vms(hostname, repo_root): + console.print("[green]✓[/green] Removed from terraform/vms.tf") + else: + console.print("[yellow]⚠[/yellow] Could not remove from terraform/vms.tf") + + # Remove from flake.nix + if flake_exists: + if remove_from_flake_nix(hostname, repo_root): + console.print("[green]✓[/green] Removed from flake.nix") + else: + console.print("[yellow]⚠[/yellow] Could not remove from flake.nix") + + # Delete hosts// directory + shutil.rmtree(host_dir) + console.print(f"[green]✓[/green] Deleted hosts/{hostname}/") + + # Success message + console.print(f"\n[bold green]✓ Host {hostname} removed successfully![/bold green]\n") + + # Display next steps + display_removal_next_steps(hostname, vault_exists) + + +def display_removal_next_steps(hostname: str, had_vault: bool) -> None: + """Display next steps after successful removal.""" + vault_file = " terraform/vault/hosts-generated.tf" if had_vault else "" + vault_apply = "" + if had_vault: + vault_apply = f""" +3. Apply Vault changes: + [white]cd terraform/vault && tofu apply[/white] +""" + + next_steps = f"""[bold cyan]Next Steps:[/bold cyan] + +1. Review changes: + [white]git diff[/white] + +2. If VM exists in Proxmox, destroy it first: + [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} + git commit -m "hosts: remove {hostname}"[/white] +""" + console.print(Panel(next_steps, border_style="cyan")) + + def display_config_summary(config: HostConfig) -> None: """Display configuration summary table.""" table = Table(title="Host Configuration", show_header=False) diff --git a/scripts/create-host/manipulators.py b/scripts/create-host/manipulators.py index 8d21668..b9c4f6e 100644 --- a/scripts/create-host/manipulators.py +++ b/scripts/create-host/manipulators.py @@ -2,10 +2,138 @@ import re from pathlib import Path +from typing import Tuple from models import HostConfig +def remove_from_flake_nix(hostname: str, repo_root: Path) -> bool: + """ + Remove host entry from flake.nix nixosConfigurations. + + Args: + hostname: Hostname to remove + repo_root: Path to repository root + + Returns: + True if found and removed, False if not found + """ + flake_path = repo_root / "flake.nix" + content = flake_path.read_text() + + # Check if hostname exists + hostname_pattern = rf"^ {re.escape(hostname)} = nixpkgs\.lib\.nixosSystem" + if not re.search(hostname_pattern, content, re.MULTILINE): + return False + + # Match the entire block from "hostname = " to "};" + replace_pattern = rf"^ {re.escape(hostname)} = nixpkgs\.lib\.nixosSystem \{{.*?^ \}};\n" + new_content, count = re.subn(replace_pattern, "", content, flags=re.MULTILINE | re.DOTALL) + + if count == 0: + return False + + flake_path.write_text(new_content) + return True + + +def remove_from_terraform_vms(hostname: str, repo_root: Path) -> bool: + """ + Remove VM entry from terraform/vms.tf locals.vms map. + + Args: + hostname: Hostname to remove + repo_root: Path to repository root + + Returns: + True if found and removed, False if not found + """ + terraform_path = repo_root / "terraform" / "vms.tf" + content = terraform_path.read_text() + + # Check if hostname exists + 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 } + replace_pattern = rf'^\s+"{re.escape(hostname)}" = \{{.*?^\s+\}}\n' + new_content, count = re.subn(replace_pattern, "", content, flags=re.MULTILINE | re.DOTALL) + + if count == 0: + return False + + terraform_path.write_text(new_content) + return True + + +def remove_from_vault_terraform(hostname: str, repo_root: Path) -> bool: + """ + Remove host policy from terraform/vault/hosts-generated.tf. + + Args: + hostname: Hostname to remove + repo_root: Path to repository root + + Returns: + True if found and removed, False if not found + """ + vault_tf_path = repo_root / "terraform" / "vault" / "hosts-generated.tf" + + if not vault_tf_path.exists(): + return False + + content = vault_tf_path.read_text() + + # Check if hostname exists in the policies + if f'"{hostname}"' not in content: + return False + + # Match the host entry block within generated_host_policies + # Pattern matches: "hostname" = { ... } with possible trailing newlines + replace_pattern = rf'\s*"{re.escape(hostname)}" = \{{\s*paths = \[.*?\]\s*\}}\n?' + new_content, count = re.subn(replace_pattern, "", content, flags=re.DOTALL) + + if count == 0: + return False + + vault_tf_path.write_text(new_content) + return True + + +def check_entries_exist(hostname: str, repo_root: Path) -> Tuple[bool, bool, bool]: + """ + Check which entries exist for a hostname. + + Args: + hostname: Hostname to check + repo_root: Path to repository root + + Returns: + Tuple of (flake_exists, terraform_vms_exists, vault_exists) + """ + # Check flake.nix + flake_path = repo_root / "flake.nix" + flake_content = flake_path.read_text() + flake_pattern = rf"^ {re.escape(hostname)} = nixpkgs\.lib\.nixosSystem" + flake_exists = bool(re.search(flake_pattern, flake_content, re.MULTILINE)) + + # Check terraform/vms.tf + terraform_path = repo_root / "terraform" / "vms.tf" + terraform_content = terraform_path.read_text() + terraform_pattern = rf'^\s+"{re.escape(hostname)}" = \{{' + terraform_exists = bool(re.search(terraform_pattern, terraform_content, re.MULTILINE)) + + # Check terraform/vault/hosts-generated.tf + vault_tf_path = repo_root / "terraform" / "vault" / "hosts-generated.tf" + vault_exists = False + if vault_tf_path.exists(): + vault_content = vault_tf_path.read_text() + vault_exists = f'"{hostname}"' in vault_content + + return (flake_exists, terraform_exists, vault_exists) + + def update_flake_nix(config: HostConfig, repo_root: Path, force: bool = False) -> None: """ Add or update host entry in flake.nix nixosConfigurations. -- 2.49.1