Merge pull request 'scripts: add create-host tool for automated host configuration generation' (#6) from phase2-host-config-generator into master
Some checks failed
Run nix flake check / flake-check (push) Failing after 1m50s
Some checks failed
Run nix flake check / flake-check (push) Failing after 1m50s
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
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
|
||||
|
||||
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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 { })
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
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