Implements Phase 2 of the automated deployment pipeline. This commit adds a Python CLI tool that automates the creation of NixOS host configurations, eliminating manual boilerplate and reducing errors. Features: - Python CLI using typer framework with rich terminal UI - Comprehensive validation (hostname format/uniqueness, IP subnet/uniqueness) - Jinja2 templates for NixOS configurations - Automatic updates to flake.nix and terraform/vms.tf - Support for both static IP and DHCP configurations - Dry-run mode for safe previews - Packaged as Nix derivation and added to devShell Usage: create-host --hostname myhost --ip 10.69.13.50/24 The tool generates: - hosts/<hostname>/default.nix - hosts/<hostname>/configuration.nix - Updates flake.nix with new nixosConfigurations entry - Updates terraform/vms.tf with new VM definition All generated configurations include full system imports (monitoring, SOPS, autoupgrade, etc.) and are validated with nix flake check and tofu validate. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
160 lines
4.5 KiB
Python
160 lines
4.5 KiB
Python
"""Validation functions for host configuration."""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
|
|
def validate_hostname_format(hostname: str) -> None:
|
|
"""
|
|
Validate hostname format according to RFC 1123.
|
|
|
|
Args:
|
|
hostname: Hostname to validate
|
|
|
|
Raises:
|
|
ValueError: If hostname format is invalid
|
|
"""
|
|
# RFC 1123: lowercase, alphanumeric, hyphens, max 63 chars
|
|
pattern = r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"
|
|
|
|
if not re.match(pattern, hostname):
|
|
raise ValueError(
|
|
f"Invalid hostname '{hostname}'. "
|
|
"Must be lowercase alphanumeric with hyphens, "
|
|
"start and end with alphanumeric, max 63 characters."
|
|
)
|
|
|
|
|
|
def validate_hostname_unique(hostname: str, repo_root: Path) -> None:
|
|
"""
|
|
Validate that hostname is unique in the repository.
|
|
|
|
Args:
|
|
hostname: Hostname to check
|
|
repo_root: Path to repository root
|
|
|
|
Raises:
|
|
ValueError: If hostname already exists
|
|
"""
|
|
# Check if host directory exists
|
|
host_dir = repo_root / "hosts" / hostname
|
|
if host_dir.exists():
|
|
raise ValueError(f"Host directory already exists: {host_dir}")
|
|
|
|
# Check if hostname exists in flake.nix
|
|
flake_path = repo_root / "flake.nix"
|
|
if flake_path.exists():
|
|
flake_content = flake_path.read_text()
|
|
# Look for pattern like " hostname = "
|
|
hostname_pattern = rf'^\s+{re.escape(hostname)}\s*='
|
|
if re.search(hostname_pattern, flake_content, re.MULTILINE):
|
|
raise ValueError(f"Hostname '{hostname}' already exists in flake.nix")
|
|
|
|
|
|
def validate_ip_format(ip: str) -> None:
|
|
"""
|
|
Validate IP address format with CIDR notation.
|
|
|
|
Args:
|
|
ip: IP address with CIDR (e.g., "10.69.13.50/24")
|
|
|
|
Raises:
|
|
ValueError: If IP format is invalid
|
|
"""
|
|
if not ip:
|
|
return
|
|
|
|
# Check CIDR notation
|
|
if "/" not in ip:
|
|
raise ValueError(f"IP address must include CIDR notation (e.g., {ip}/24)")
|
|
|
|
ip_part, cidr_part = ip.rsplit("/", 1)
|
|
|
|
# Validate CIDR is /24
|
|
if cidr_part != "24":
|
|
raise ValueError(f"CIDR notation must be /24, got /{cidr_part}")
|
|
|
|
# Validate IP format
|
|
octets = ip_part.split(".")
|
|
if len(octets) != 4:
|
|
raise ValueError(f"Invalid IP address format: {ip_part}")
|
|
|
|
try:
|
|
octet_values = [int(octet) for octet in octets]
|
|
except ValueError:
|
|
raise ValueError(f"Invalid IP address format: {ip_part}")
|
|
|
|
# Check each octet is 0-255
|
|
for i, value in enumerate(octet_values):
|
|
if not 0 <= value <= 255:
|
|
raise ValueError(f"Invalid octet value {value} in IP address")
|
|
|
|
# Check last octet is 1-254
|
|
if not 1 <= octet_values[3] <= 254:
|
|
raise ValueError(
|
|
f"Last octet must be 1-254, got {octet_values[3]}"
|
|
)
|
|
|
|
|
|
def validate_ip_subnet(ip: str) -> None:
|
|
"""
|
|
Validate that IP address is in the correct subnet (10.69.13.0/24).
|
|
|
|
Args:
|
|
ip: IP address with CIDR (e.g., "10.69.13.50/24")
|
|
|
|
Raises:
|
|
ValueError: If IP is not in correct subnet
|
|
"""
|
|
if not ip:
|
|
return
|
|
|
|
validate_ip_format(ip)
|
|
|
|
ip_part = ip.split("/")[0]
|
|
octets = ip_part.split(".")
|
|
|
|
# Check subnet is 10.69.13.x
|
|
if octets[:3] != ["10", "69", "13"]:
|
|
raise ValueError(
|
|
f"IP address must be in 10.69.13.0/24 subnet, got {ip_part}"
|
|
)
|
|
|
|
|
|
def validate_ip_unique(ip: Optional[str], repo_root: Path) -> None:
|
|
"""
|
|
Validate that IP address is not already in use.
|
|
|
|
Args:
|
|
ip: IP address with CIDR to check (None for DHCP)
|
|
repo_root: Path to repository root
|
|
|
|
Raises:
|
|
ValueError: If IP is already in use
|
|
"""
|
|
if not ip:
|
|
return # DHCP mode, no uniqueness check needed
|
|
|
|
# Extract just the IP part without CIDR for searching
|
|
ip_part = ip.split("/")[0]
|
|
|
|
# Check all hosts/*/configuration.nix files
|
|
hosts_dir = repo_root / "hosts"
|
|
if hosts_dir.exists():
|
|
for config_file in hosts_dir.glob("*/configuration.nix"):
|
|
content = config_file.read_text()
|
|
if ip_part in content:
|
|
raise ValueError(
|
|
f"IP address {ip_part} already in use in {config_file}"
|
|
)
|
|
|
|
# Check terraform/vms.tf
|
|
terraform_file = repo_root / "terraform" / "vms.tf"
|
|
if terraform_file.exists():
|
|
content = terraform_file.read_text()
|
|
if ip_part in content:
|
|
raise ValueError(
|
|
f"IP address {ip_part} already in use in {terraform_file}"
|
|
)
|