scripts: add create-host tool for automated host configuration generation #6
62
TODO.md
62
TODO.md
@@ -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
|
**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:**
|
**Tasks:**
|
||||||
- [ ] Create script `scripts/create-host-config.sh`
|
- [x] Create Python CLI with typer framework
|
||||||
- [ ] Takes parameters: hostname, IP, CPU cores, memory, disk size
|
- [x] Takes parameters: hostname, IP, CPU cores, memory, disk size
|
||||||
- [ ] Generates `/hosts/<hostname>/` directory structure from template
|
- [x] Generates `/hosts/<hostname>/` directory structure
|
||||||
- [ ] Creates `configuration.nix` with proper hostname and networking
|
- [x] Creates `configuration.nix` with proper hostname and networking
|
||||||
- [ ] Generates `default.nix` with standard imports
|
- [x] Generates `default.nix` with standard imports
|
||||||
- [ ] Copies/links `hardware-configuration.nix` from template
|
- [x] References shared `hardware-configuration.nix` from template
|
||||||
- [ ] Add host entry to `flake.nix` programmatically
|
- [x] Add host entry to `flake.nix` programmatically
|
||||||
- [ ] Parse flake.nix
|
- [x] Text-based manipulation (regex insertion)
|
||||||
- [ ] Insert new nixosConfiguration entry
|
- [x] Inserts new nixosConfiguration entry
|
||||||
- [ ] Maintain formatting
|
- [x] Maintains proper formatting
|
||||||
- [ ] Generate corresponding OpenTofu configuration
|
- [x] Generate corresponding OpenTofu configuration
|
||||||
- [ ] Create `terraform/hosts/<hostname>.tf` with VM definition
|
- [x] Adds VM definition to `terraform/vms.tf`
|
||||||
- [ ] Use parameters from script input
|
- [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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,12 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
packages = forAllSystems (
|
||||||
|
{ pkgs }:
|
||||||
|
{
|
||||||
|
create-host = pkgs.callPackage ./scripts/create-host { };
|
||||||
|
}
|
||||||
|
);
|
||||||
devShells = forAllSystems (
|
devShells = forAllSystems (
|
||||||
{ pkgs }:
|
{ pkgs }:
|
||||||
{
|
{
|
||||||
@@ -342,7 +348,7 @@
|
|||||||
packages = with pkgs; [
|
packages = with pkgs; [
|
||||||
ansible
|
ansible
|
||||||
opentofu
|
opentofu
|
||||||
python3
|
(pkgs.callPackage ./scripts/create-host { })
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
1
scripts/create-host/MANIFEST.in
Normal file
1
scripts/create-host/MANIFEST.in
Normal file
@@ -0,0 +1 @@
|
|||||||
|
recursive-include templates *.j2
|
||||||
247
scripts/create-host/README.md
Normal file
247
scripts/create-host/README.md
Normal 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.
|
||||||
3
scripts/create-host/__init__.py
Normal file
3
scripts/create-host/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""NixOS host configuration generator for homelab infrastructure."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
6
scripts/create-host/__main__.py
Normal file
6
scripts/create-host/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Entry point for running the create-host module."""
|
||||||
|
|
||||||
|
from .create_host import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
186
scripts/create-host/create_host.py
Normal file
186
scripts/create-host/create_host.py
Normal 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()
|
||||||
38
scripts/create-host/default.nix
Normal file
38
scripts/create-host/default.nix
Normal 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 = [ ];
|
||||||
|
};
|
||||||
|
}
|
||||||
88
scripts/create-host/generators.py
Normal file
88
scripts/create-host/generators.py
Normal 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)
|
||||||
100
scripts/create-host/manipulators.py
Normal file
100
scripts/create-host/manipulators.py
Normal 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)
|
||||||
54
scripts/create-host/models.py
Normal file
54
scripts/create-host/models.py
Normal 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")
|
||||||
33
scripts/create-host/setup.py
Normal file
33
scripts/create-host/setup.py
Normal 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",
|
||||||
|
)
|
||||||
67
scripts/create-host/templates/configuration.nix.j2
Normal file
67
scripts/create-host/templates/configuration.nix.j2
Normal 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?
|
||||||
|
}
|
||||||
5
scripts/create-host/templates/default.nix.j2
Normal file
5
scripts/create-host/templates/default.nix.j2
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{ ... }: {
|
||||||
|
imports = [
|
||||||
|
./configuration.nix
|
||||||
|
];
|
||||||
|
}
|
||||||
159
scripts/create-host/validators.py
Normal file
159
scripts/create-host/validators.py
Normal 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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user