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
Showing only changes of commit 86249c466b - Show all commits

View File

@@ -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/<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:
"""Display configuration summary table."""
table = Table(title="Host Configuration", show_header=False)

View File

@@ -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.