create-host: add delete feature #13
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user