create-host: add delete feature #13

Merged
torjus merged 1 commits from create-host-delete-feature into master 2026-02-03 12:06:32 +00:00
2 changed files with 305 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
"""CLI tool for generating NixOS host configurations.""" """CLI tool for generating NixOS host configurations."""
import shutil
import sys import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -10,7 +11,15 @@ from rich.panel import Panel
from rich.table import Table from rich.table import Table
from generators import generate_host_files, generate_vault_terraform 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 models import HostConfig
from vault_helper import generate_wrapped_token from vault_helper import generate_wrapped_token
from validators import ( from validators import (
@@ -46,9 +55,10 @@ def main(
memory: int = typer.Option(2048, "--memory", help="Memory in MB"), memory: int = typer.Option(2048, "--memory", help="Memory in MB"),
disk: str = typer.Option("20G", "--disk", help="Disk size (e.g., 20G, 50G, 100G)"), 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"), 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"), 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)"), 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: ) -> None:
""" """
Create a new NixOS host configuration. Create a new NixOS host configuration.
@@ -64,6 +74,11 @@ def main(
# Get repository root # Get repository root
repo_root = get_repo_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 # Handle token regeneration mode
if regenerate_token: if regenerate_token:
# Validate that incompatible options aren't used # Validate that incompatible options aren't used
@@ -198,6 +213,166 @@ def main(
sys.exit(1) 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/<hostname>/ 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: def display_config_summary(config: HostConfig) -> None:
"""Display configuration summary table.""" """Display configuration summary table."""
table = Table(title="Host Configuration", show_header=False) table = Table(title="Host Configuration", show_header=False)

View File

@@ -2,10 +2,138 @@
import re import re
from pathlib import Path from pathlib import Path
from typing import Tuple
from models import HostConfig 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: def update_flake_nix(config: HostConfig, repo_root: Path, force: bool = False) -> None:
""" """
Add or update host entry in flake.nix nixosConfigurations. Add or update host entry in flake.nix nixosConfigurations.