278 lines
10 KiB
Python
278 lines
10 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"),
|
|
regenerate_token: bool = typer.Option(False, "--regenerate-token", help="Only regenerate Vault wrapped token (no other changes)"),
|
|
) -> 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 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 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()
|