scripts: add create-host tool for automated host configuration generation #6

Merged
torjus merged 1 commits from phase2-host-config-generator into master 2026-02-01 01:48:19 +00:00
15 changed files with 1040 additions and 17 deletions
Showing only changes of commit 408554b477 - Show all commits

62
TODO.md
View File

@@ -50,28 +50,58 @@ Automate the entire process of creating, configuring, and deploying new NixOS ho
---
### Phase 2: Host Configuration Generator
### Phase 2: Host Configuration Generator ✅ COMPLETED
**Status:** ✅ Fully implemented and tested
**Completed:** 2025-02-01
**Goal:** Automate creation of host configuration files
Doesn't have to be a plain shell script, we could also use something like python, would probably make templating easier.
**Implementation:**
- Python CLI tool packaged as Nix derivation
- Available as `create-host` command in devShell
- Rich terminal UI with configuration previews
- Comprehensive validation (hostname format/uniqueness, IP subnet/uniqueness)
- Jinja2 templates for NixOS configurations
- Automatic updates to flake.nix and terraform/vms.tf
**Tasks:**
- [ ] Create script `scripts/create-host-config.sh`
- [ ] Takes parameters: hostname, IP, CPU cores, memory, disk size
- [ ] Generates `/hosts/<hostname>/` directory structure from template
- [ ] Creates `configuration.nix` with proper hostname and networking
- [ ] Generates `default.nix` with standard imports
- [ ] Copies/links `hardware-configuration.nix` from template
- [ ] Add host entry to `flake.nix` programmatically
- [ ] Parse flake.nix
- [ ] Insert new nixosConfiguration entry
- [ ] Maintain formatting
- [ ] Generate corresponding OpenTofu configuration
- [ ] Create `terraform/hosts/<hostname>.tf` with VM definition
- [ ] Use parameters from script input
- [x] Create Python CLI with typer framework
- [x] Takes parameters: hostname, IP, CPU cores, memory, disk size
- [x] Generates `/hosts/<hostname>/` directory structure
- [x] Creates `configuration.nix` with proper hostname and networking
- [x] Generates `default.nix` with standard imports
- [x] References shared `hardware-configuration.nix` from template
- [x] Add host entry to `flake.nix` programmatically
- [x] Text-based manipulation (regex insertion)
- [x] Inserts new nixosConfiguration entry
- [x] Maintains proper formatting
- [x] Generate corresponding OpenTofu configuration
- [x] Adds VM definition to `terraform/vms.tf`
- [x] Uses parameters from CLI input
- [x] Supports both static IP and DHCP modes
- [x] Package as Nix derivation with templates
- [x] Add to flake packages and devShell
- [x] Implement dry-run mode
- [x] Write comprehensive README
**Deliverable:** Script generates all config files for a new host
**Usage:**
```bash
# In nix develop shell
create-host \
--hostname test01 \
--ip 10.69.13.50/24 \ # optional, omit for DHCP
--cpu 4 \ # optional, default 2
--memory 4096 \ # optional, default 2048
--disk 50G \ # optional, default 20G
--dry-run # optional preview mode
```
**Files:**
- `scripts/create-host/` - Complete Python package with Nix derivation
- `scripts/create-host/README.md` - Full documentation and examples
**Deliverable:** ✅ Tool generates all config files for a new host, validated with Nix and Terraform
---

View File

@@ -335,6 +335,12 @@
];
};
};
packages = forAllSystems (
{ pkgs }:
{
create-host = pkgs.callPackage ./scripts/create-host { };
}
);
devShells = forAllSystems (
{ pkgs }:
{
@@ -342,7 +348,7 @@
packages = with pkgs; [
ansible
opentofu
python3
(pkgs.callPackage ./scripts/create-host { })
];
};
}

View File

@@ -0,0 +1 @@
recursive-include templates *.j2

View File

@@ -0,0 +1,247 @@
# NixOS Host Configuration Generator
Automated tool for generating NixOS host configurations, flake.nix entries, and Terraform VM definitions for homelab infrastructure.
## Installation
The tool is available in the Nix development shell:
```bash
nix develop
```
## Usage
### Basic Usage
Create a new host with DHCP networking:
```bash
python -m scripts.create_host.create_host create --hostname test01
```
Create a new host with static IP:
```bash
python -m scripts.create_host.create_host create \
--hostname test01 \
--ip 10.69.13.50/24
```
Create a host with custom resources:
```bash
python -m scripts.create_host.create_host create \
--hostname bighost01 \
--ip 10.69.13.51/24 \
--cpu 8 \
--memory 8192 \
--disk 100G
```
### Dry Run Mode
Preview what would be created without making changes:
```bash
python -m scripts.create_host.create_host create \
--hostname test01 \
--ip 10.69.13.50/24 \
--dry-run
```
### Options
- `--hostname` (required): Hostname for the new host
- Must be lowercase alphanumeric with hyphens
- Must be unique (not already exist in repository)
- `--ip` (optional): Static IP address with CIDR notation
- Format: `10.69.13.X/24`
- Must be in `10.69.13.0/24` subnet
- Last octet must be 1-254
- Omit this option for DHCP configuration
- `--cpu` (optional, default: 2): Number of CPU cores
- Must be at least 1
- `--memory` (optional, default: 2048): Memory in MB
- Must be at least 512
- `--disk` (optional, default: "20G"): Disk size
- Examples: "20G", "50G", "100G"
- `--dry-run` (flag): Preview changes without creating files
## What It Does
The tool performs the following actions:
1. **Validates** the configuration:
- Hostname format (RFC 1123 compliance)
- Hostname uniqueness
- IP address format and subnet (if provided)
- IP address uniqueness (if provided)
2. **Generates** host configuration files:
- `hosts/<hostname>/default.nix` - Import wrapper
- `hosts/<hostname>/configuration.nix` - Full host configuration
3. **Updates** repository files:
- `flake.nix` - Adds new nixosConfigurations entry
- `terraform/vms.tf` - Adds new VM definition
4. **Displays** next steps for:
- Reviewing changes with git diff
- Verifying NixOS configuration
- Verifying Terraform configuration
- Committing changes
- Deploying the VM
## Generated Configuration
### Host Features
All generated hosts include:
- Full system imports from `../../system`:
- Nix binary cache integration
- SSH with root login
- SOPS secrets management
- Internal ACME CA integration
- Daily auto-upgrades with auto-reboot
- Prometheus node-exporter
- Promtail logging to monitoring01
- VM guest agent from `../../common/vm`
- Hardware configuration from `../template/hardware-configuration.nix`
### Networking
**Static IP mode** (when `--ip` is provided):
```nix
systemd.network.networks."ens18" = {
matchConfig.Name = "ens18";
address = [ "10.69.13.50/24" ];
routes = [ { Gateway = "10.69.13.1"; } ];
linkConfig.RequiredForOnline = "routable";
};
```
**DHCP mode** (when `--ip` is omitted):
```nix
systemd.network.networks."ens18" = {
matchConfig.Name = "ens18";
networkConfig.DHCP = "ipv4";
linkConfig.RequiredForOnline = "routable";
};
```
### DNS Configuration
All hosts are configured with:
- DNS servers: `10.69.13.5`, `10.69.13.6` (ns1, ns2)
- Domain: `home.2rjus.net`
## Examples
### Create a test VM with defaults
```bash
python -m scripts.create_host.create_host create --hostname test99
```
This creates a DHCP VM with 2 CPU cores, 2048 MB memory, and 20G disk.
### Create a database server with static IP
```bash
python -m scripts.create_host.create_host create \
--hostname pgdb2 \
--ip 10.69.13.52/24 \
--cpu 4 \
--memory 4096 \
--disk 50G
```
### Preview changes before creating
```bash
python -m scripts.create_host.create_host create \
--hostname test99 \
--ip 10.69.13.99/24 \
--dry-run
```
## Error Handling
The tool validates input and provides clear error messages for:
- Invalid hostname format (must be lowercase alphanumeric with hyphens)
- Duplicate hostname (already exists in repository)
- Invalid IP format (must be X.X.X.X/24)
- Wrong subnet (must be 10.69.13.0/24)
- Invalid last octet (must be 1-254)
- Duplicate IP address (already in use)
- Resource constraints (CPU < 1, memory < 512 MB)
## Integration with Deployment Pipeline
This tool implements **Phase 2** of the automated deployment pipeline:
1. **Phase 1**: Template building ✓ (build-and-deploy-template.yml)
2. **Phase 2**: Host configuration generation ✓ (this tool)
3. **Phase 3**: Bootstrap automation (planned)
4. **Phase 4**: Secrets management (planned)
5. **Phase 5**: DNS automation (planned)
6. **Phase 6**: Full integration (planned)
## Development
### Project Structure
```
scripts/create-host/
├── create_host.py # Main CLI entry point (typer app)
├── __init__.py # Package initialization
├── validators.py # Validation logic
├── generators.py # File generation using Jinja2
├── manipulators.py # Text manipulation for flake.nix and vms.tf
├── models.py # Data models (HostConfig)
├── templates/
│ ├── default.nix.j2 # Template for default.nix
│ └── configuration.nix.j2 # Template for configuration.nix
└── README.md # This file
```
### Testing
Run the test cases from the implementation plan:
```bash
# Test 1: DHCP host with defaults
python -m scripts.create_host.create_host create --hostname testdhcp --dry-run
# Test 2: Static IP host
python -m scripts.create_host.create_host create \
--hostname test50 --ip 10.69.13.50/24 --dry-run
# Test 3: Custom resources
python -m scripts.create_host.create_host create \
--hostname test51 --ip 10.69.13.51/24 \
--cpu 8 --memory 8192 --disk 100G --dry-run
# Test 4: Duplicate hostname (should error)
python -m scripts.create_host.create_host create --hostname ns1 --dry-run
# Test 5: Invalid subnet (should error)
python -m scripts.create_host.create_host create \
--hostname testbad --ip 192.168.1.50/24 --dry-run
# Test 6: Invalid hostname (should error)
python -m scripts.create_host.create_host create --hostname Test_Host --dry-run
```
## License
Part of the nixos-servers homelab infrastructure repository.

View File

@@ -0,0 +1,3 @@
"""NixOS host configuration generator for homelab infrastructure."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,6 @@
"""Entry point for running the create-host module."""
from .create_host import app
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,186 @@
"""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
from manipulators import update_flake_nix, update_terraform_vms
from models import HostConfig
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"),
) -> 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)
validate_hostname_unique(hostname, repo_root)
if ip:
validate_ip_subnet(ip)
validate_ip_unique(ip, repo_root)
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)
console.print(f"[green]✓[/green] Created hosts/{hostname}/default.nix")
console.print(f"[green]✓[/green] Created hosts/{hostname}/configuration.nix")
update_flake_nix(config, repo_root)
console.print("[green]✓[/green] Updated flake.nix")
update_terraform_vms(config, repo_root)
console.print("[green]✓[/green] Updated terraform/vms.tf")
# Success message
console.print("\n[bold green]✓ Host configuration generated successfully![/bold green]\n")
# Display next steps
display_next_steps(hostname)
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) -> None:
"""Display next steps after successful generation."""
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
git commit -m "hosts: add {hostname} configuration"[/white]
5. Deploy VM (after merging to master):
[white]cd terraform
tofu apply[/white]
6. Bootstrap the host (see Phase 3 of deployment pipeline)
"""
console.print(Panel(next_steps, border_style="cyan"))
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,38 @@
{ lib
, python3
, python3Packages
}:
python3Packages.buildPythonApplication {
pname = "create-host";
version = "0.1.0";
src = ./.;
pyproject = true;
build-system = with python3Packages; [
setuptools
];
propagatedBuildInputs = with python3Packages; [
typer
jinja2
rich
];
# Install templates to share directory
postInstall = ''
mkdir -p $out/share/create-host
cp -r templates $out/share/create-host/
'';
# No tests yet
doCheck = false;
meta = with lib; {
description = "NixOS host configuration generator for homelab infrastructure";
license = licenses.mit;
maintainers = [ ];
};
}

View File

@@ -0,0 +1,88 @@
"""File generation using Jinja2 templates."""
import sys
from pathlib import Path
from jinja2 import Environment, BaseLoader, TemplateNotFound
from models import HostConfig
class PackageTemplateLoader(BaseLoader):
"""Custom Jinja2 loader that works with both dev and installed packages."""
def __init__(self):
# Try to find templates in multiple locations
self.template_dirs = []
# Location 1: Development (scripts/create-host/templates)
dev_dir = Path(__file__).parent / "templates"
if dev_dir.exists():
self.template_dirs.append(dev_dir)
# Location 2: Installed via Nix (../share/create-host/templates from bin dir)
# When installed via Nix, __file__ is in lib/python3.X/site-packages/
# and templates are in ../../../share/create-host/templates
for site_path in sys.path:
site_dir = Path(site_path)
# Try to find the Nix store path
if "site-packages" in str(site_dir):
# Go up to the package root (e.g., /nix/store/xxx-create-host-0.1.0)
pkg_root = site_dir.parent.parent.parent
share_templates = pkg_root / "share" / "create-host" / "templates"
if share_templates.exists():
self.template_dirs.append(share_templates)
# Location 3: Fallback - sys.path templates
for site_path in sys.path:
site_templates = Path(site_path) / "templates"
if site_templates.exists():
self.template_dirs.append(site_templates)
def get_source(self, environment, template):
for template_dir in self.template_dirs:
template_path = template_dir / template
if template_path.exists():
mtime = template_path.stat().st_mtime
source = template_path.read_text()
return source, str(template_path), lambda: mtime == template_path.stat().st_mtime
raise TemplateNotFound(template)
def generate_host_files(config: HostConfig, repo_root: Path) -> None:
"""
Generate host configuration files from templates.
Args:
config: Host configuration
repo_root: Path to repository root
"""
# Setup Jinja2 environment with custom loader
env = Environment(
loader=PackageTemplateLoader(),
trim_blocks=True,
lstrip_blocks=True,
)
# Create host directory
host_dir = repo_root / "hosts" / config.hostname
host_dir.mkdir(parents=True, exist_ok=True)
# Generate default.nix
default_template = env.get_template("default.nix.j2")
default_content = default_template.render(hostname=config.hostname)
(host_dir / "default.nix").write_text(default_content)
# Generate configuration.nix
config_template = env.get_template("configuration.nix.j2")
config_content = config_template.render(
hostname=config.hostname,
domain=config.domain,
nameservers=config.nameservers,
is_static_ip=config.is_static_ip,
ip=config.ip,
gateway=config.gateway,
state_version=config.state_version,
)
(host_dir / "configuration.nix").write_text(config_content)

View File

@@ -0,0 +1,100 @@
"""Text manipulation for flake.nix and Terraform files."""
import re
from pathlib import Path
from models import HostConfig
def update_flake_nix(config: HostConfig, repo_root: Path) -> None:
"""
Add new host entry to flake.nix nixosConfigurations.
Args:
config: Host configuration
repo_root: Path to repository root
"""
flake_path = repo_root / "flake.nix"
content = flake_path.read_text()
# Find the closing of nixosConfigurations block
# Pattern: " };\n packages ="
pattern = r"( \};)\n( packages =)"
# Create new entry
new_entry = f""" {config.hostname} = nixpkgs.lib.nixosSystem {{
inherit system;
specialArgs = {{
inherit inputs self sops-nix;
}};
modules = [
(
{{ config, pkgs, ... }}:
{{
nixpkgs.overlays = commonOverlays;
}}
)
./hosts/{config.hostname}
sops-nix.nixosModules.sops
];
}};
"""
# Insert new entry before closing brace
replacement = rf"\g<1>\n{new_entry}\g<2>"
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
raise ValueError(
"Could not find insertion point in flake.nix. "
"Looking for pattern: ' };\\n devShells ='"
)
flake_path.write_text(new_content)
def update_terraform_vms(config: HostConfig, repo_root: Path) -> None:
"""
Add new VM entry to terraform/vms.tf locals.vms map.
Args:
config: Host configuration
repo_root: Path to repository root
"""
terraform_path = repo_root / "terraform" / "vms.tf"
content = terraform_path.read_text()
# Find the closing of locals.vms block
# Pattern: " }\n\n # Compute VM configurations"
pattern = r"( \})\n\n( # Compute VM configurations)"
# Create new entry based on whether we have static IP or DHCP
if config.is_static_ip:
new_entry = f''' "{config.hostname}" = {{
ip = "{config.ip}"
cpu_cores = {config.cpu}
memory = {config.memory}
disk_size = "{config.disk}"
}}
'''
else:
new_entry = f''' "{config.hostname}" = {{
cpu_cores = {config.cpu}
memory = {config.memory}
disk_size = "{config.disk}"
}}
'''
# Insert new entry before closing brace
replacement = rf"{new_entry}\g<1>\n\n\g<2>"
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
raise ValueError(
"Could not find insertion point in terraform/vms.tf. "
"Looking for pattern: ' }\\n\\n # Compute VM configurations'"
)
terraform_path.write_text(new_content)

View File

@@ -0,0 +1,54 @@
"""Data models for host configuration."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class HostConfig:
"""Configuration for a new NixOS host."""
hostname: str
ip: Optional[str] = None
cpu: int = 2
memory: int = 2048
disk: str = "20G"
@property
def is_static_ip(self) -> bool:
"""Check if host uses static IP configuration."""
return self.ip is not None
@property
def gateway(self) -> str:
"""Default gateway for the network."""
return "10.69.13.1"
@property
def nameservers(self) -> list[str]:
"""DNS nameservers for the network."""
return ["10.69.13.5", "10.69.13.6"]
@property
def domain(self) -> str:
"""Domain name for the network."""
return "home.2rjus.net"
@property
def state_version(self) -> str:
"""NixOS state version for new hosts."""
return "25.11"
def validate(self) -> None:
"""Validate configuration constraints."""
if not self.hostname:
raise ValueError("Hostname cannot be empty")
if self.cpu < 1:
raise ValueError("CPU cores must be at least 1")
if self.memory < 512:
raise ValueError("Memory must be at least 512 MB")
if not self.disk:
raise ValueError("Disk size cannot be empty")

View File

@@ -0,0 +1,33 @@
from setuptools import setup
from pathlib import Path
# Read templates
templates = [str(p.relative_to(".")) for p in Path("templates").glob("*.j2")]
setup(
name="create-host",
version="0.1.0",
description="NixOS host configuration generator for homelab infrastructure",
py_modules=[
"create_host",
"models",
"validators",
"generators",
"manipulators",
],
include_package_data=True,
data_files=[
("templates", templates),
],
install_requires=[
"typer",
"jinja2",
"rich",
],
entry_points={
"console_scripts": [
"create-host=create_host:app",
],
},
python_requires=">=3.9",
)

View File

@@ -0,0 +1,67 @@
{
config,
lib,
pkgs,
...
}:
{
imports = [
../template/hardware-configuration.nix
../../system
../../common/vm
];
nixpkgs.config.allowUnfree = true;
# Use the systemd-boot EFI boot loader.
boot.loader.grub.enable = true;
boot.loader.grub.device = "/dev/sda";
networking.hostName = "{{ hostname }}";
networking.domain = "{{ domain }}";
networking.useNetworkd = true;
networking.useDHCP = false;
services.resolved.enable = false;
networking.nameservers = [
{% for ns in nameservers %}
"{{ ns }}"
{% endfor %}
];
systemd.network.enable = true;
systemd.network.networks."ens18" = {
matchConfig.Name = "ens18";
{% if is_static_ip %}
address = [
"{{ ip }}"
];
routes = [
{ Gateway = "{{ gateway }}"; }
];
{% else %}
networkConfig.DHCP = "ipv4";
{% endif %}
linkConfig.RequiredForOnline = "routable";
};
time.timeZone = "Europe/Oslo";
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
nix.settings.tarball-ttl = 0;
environment.systemPackages = with pkgs; [
vim
wget
git
];
# Open ports in the firewall.
# networking.firewall.allowedTCPPorts = [ ... ];
# networking.firewall.allowedUDPPorts = [ ... ];
# Or disable the firewall altogether.
networking.firewall.enable = false;
system.stateVersion = "{{ state_version }}"; # Did you read the comment?
}

View File

@@ -0,0 +1,5 @@
{ ... }: {
imports = [
./configuration.nix
];
}

View File

@@ -0,0 +1,159 @@
"""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}"
)