Files
nixos-servers/scripts/create-host/create_host.py
Torjus Håkestad 1f4b7a6cbc
Some checks failed
Run nix flake check / flake-check (push) Failing after 3m15s
vault: implement bootstrap integration
2026-02-02 22:41:15 +01:00

235 lines
8.3 KiB
Python

"""CLI tool for generating NixOS host configurations."""
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
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_vault: bool = typer.Option(False, "--skip-vault", help="Skip Vault configuration and token generation"),
) -> 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)
try:
# Build configuration
config = HostConfig(
hostname=hostname,
ip=ip,
cpu=cpu,
memory=memory,
disk=disk,
)
# Get repository root
repo_root = get_repo_root()
# 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 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()