"""CLI tool for generating NixOS host configurations.""" import shutil import sys from pathlib import Path from typing import Optional import typer from rich.console import Console 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, 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 ( validate_hostname_format, validate_hostname_unique, validate_ip_subnet, validate_ip_unique, ) app = typer.Typer( name="create-host", help="Generate NixOS host configurations for homelab infrastructure", add_completion=False, ) console = Console() def get_repo_root() -> Path: """Get the repository root directory.""" # Use current working directory as repo root # The tool should be run from the repository root return Path.cwd() @app.callback(invoke_without_command=True) def main( ctx: typer.Context, hostname: Optional[str] = typer.Option(None, "--hostname", help="Hostname for the new host"), ip: Optional[str] = typer.Option( None, "--ip", help="Static IP address with CIDR (e.g., 10.69.13.50/24). Omit for DHCP." ), cpu: int = typer.Option(2, "--cpu", help="Number of CPU cores"), 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 / 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. Generates host configuration files, updates flake.nix, and adds Terraform VM definition. """ # Show help if no hostname provided if hostname is None: console.print("[bold red]Error:[/bold red] --hostname is required\n") ctx.get_help() sys.exit(1) # 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 if force or dry_run or skip_vault: console.print("[bold red]Error:[/bold red] --regenerate-token cannot be used with --force, --dry-run, or --skip-vault\n") sys.exit(1) if ip or cpu != 2 or memory != 2048 or disk != "20G": console.print("[bold red]Error:[/bold red] --regenerate-token only regenerates the token. Other options (--ip, --cpu, --memory, --disk) are ignored.\n") console.print("[yellow]Tip:[/yellow] Use without those options, or use --force to update the entire configuration.\n") sys.exit(1) try: console.print(f"\n[bold blue]Regenerating Vault token for {hostname}...[/bold blue]") # Validate hostname exists 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) # Generate new wrapped token wrapped_token = generate_wrapped_token(hostname, repo_root) # Update only the wrapped token in vms.tf add_wrapped_token_to_vm(hostname, wrapped_token, repo_root) console.print("[green]✓[/green] Regenerated and updated wrapped token in terraform/vms.tf") console.print("\n[bold green]✓ Token regenerated successfully![/bold green]") console.print(f"\n[yellow]⚠️[/yellow] Token expires in 24 hours") console.print(f"[yellow]⚠️[/yellow] Deploy the VM within 24h or regenerate token again\n") console.print("[bold cyan]Next steps:[/bold cyan]") console.print(f" cd terraform && tofu apply") console.print(f" # Then redeploy VM to pick up new token\n") return except Exception as e: console.print(f"\n[bold red]Error regenerating token:[/bold red] {e}\n") sys.exit(1) try: # Build configuration config = HostConfig( hostname=hostname, ip=ip, cpu=cpu, memory=memory, disk=disk, ) # Validate configuration console.print("\n[bold blue]Validating configuration...[/bold blue]") config.validate() validate_hostname_format(hostname) # Skip uniqueness checks in force mode if not force: validate_hostname_unique(hostname, repo_root) if ip: validate_ip_unique(ip, repo_root) else: # Check if we're actually overwriting something host_dir = repo_root / "hosts" / hostname if host_dir.exists(): console.print(f"[yellow]⚠[/yellow] Updating existing host configuration for {hostname}") if ip: validate_ip_subnet(ip) console.print("[green]✓[/green] All validations passed\n") # Display configuration summary display_config_summary(config) # Dry run mode - exit before making changes if dry_run: console.print("\n[yellow]DRY RUN MODE - No files will be created[/yellow]\n") display_dry_run_summary(config, repo_root) return # Generate files console.print("\n[bold blue]Generating host configuration...[/bold blue]") generate_host_files(config, repo_root) action = "Updated" if force else "Created" console.print(f"[green]✓[/green] {action} hosts/{hostname}/default.nix") console.print(f"[green]✓[/green] {action} hosts/{hostname}/configuration.nix") update_flake_nix(config, repo_root, force=force) console.print("[green]✓[/green] Updated flake.nix") update_terraform_vms(config, repo_root, force=force) console.print("[green]✓[/green] Updated terraform/vms.tf") # Generate Vault configuration if not skipped if not skip_vault: console.print("\n[bold blue]Configuring Vault integration...[/bold blue]") try: # Generate Vault Terraform configuration generate_vault_terraform(hostname, repo_root) console.print("[green]✓[/green] Updated terraform/vault/hosts-generated.tf") # Generate wrapped token wrapped_token = generate_wrapped_token(hostname, repo_root) # Add wrapped token to VM configuration add_wrapped_token_to_vm(hostname, wrapped_token, repo_root) console.print("[green]✓[/green] Added wrapped token to terraform/vms.tf") except Exception as e: console.print(f"\n[yellow]⚠️ Vault configuration failed: {e}[/yellow]") console.print("[yellow]Host configuration created without Vault integration[/yellow]") console.print("[yellow]You can add Vault support later by re-running with --force[/yellow]\n") else: console.print("\n[yellow]Skipped Vault configuration (--skip-vault)[/yellow]") # Success message console.print("\n[bold green]✓ Host configuration generated successfully![/bold green]\n") # Display next steps display_next_steps(hostname, skip_vault=skip_vault) except ValueError as e: console.print(f"\n[bold red]Error:[/bold red] {e}\n", style="red") sys.exit(1) except Exception as e: console.print(f"\n[bold red]Unexpected error:[/bold red] {e}\n", style="red") 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) table.add_column("Property", style="cyan") table.add_column("Value", style="white") table.add_row("Hostname", config.hostname) table.add_row("Domain", config.domain) table.add_row("Network Mode", "Static IP" if config.is_static_ip else "DHCP") if config.is_static_ip: table.add_row("IP Address", config.ip) table.add_row("Gateway", config.gateway) table.add_row("DNS Servers", ", ".join(config.nameservers)) table.add_row("CPU Cores", str(config.cpu)) table.add_row("Memory", f"{config.memory} MB") table.add_row("Disk Size", config.disk) table.add_row("State Version", config.state_version) console.print(table) def display_dry_run_summary(config: HostConfig, repo_root: Path) -> None: """Display what would be created in dry run mode.""" console.print("[bold]Files that would be created:[/bold]") console.print(f" • {repo_root}/hosts/{config.hostname}/default.nix") console.print(f" • {repo_root}/hosts/{config.hostname}/configuration.nix") console.print("\n[bold]Files that would be modified:[/bold]") console.print(f" • {repo_root}/flake.nix (add nixosConfigurations.{config.hostname})") console.print(f" • {repo_root}/terraform/vms.tf (add VM definition)") def display_next_steps(hostname: str, skip_vault: bool = False) -> None: """Display next steps after successful generation.""" vault_files = "" if skip_vault else " terraform/vault/hosts-generated.tf" vault_apply = "" if not skip_vault: vault_apply = """ 4a. Apply Vault configuration: [white]cd terraform/vault tofu apply[/white] """ next_steps = f"""[bold cyan]Next Steps:[/bold cyan] 1. Review changes: [white]git diff[/white] 2. Verify NixOS configuration: [white]nix flake check nix build .#nixosConfigurations.{hostname}.config.system.build.toplevel[/white] 3. Verify Terraform configuration: [white]cd terraform tofu validate tofu plan[/white] 4. Commit changes: [white]git add hosts/{hostname} flake.nix terraform/vms.tf{vault_files} git commit -m "hosts: add {hostname} configuration"[/white] {vault_apply} 5. Deploy VM (after merging to master or within 24h of token generation): [white]cd terraform tofu apply[/white] 6. Host will bootstrap automatically on first boot - Wrapped token expires in 24 hours - If expired, re-run: create-host --hostname {hostname} --force """ console.print(Panel(next_steps, border_style="cyan")) if __name__ == "__main__": app()