Initial implementation: host lifecycle + PXE + admin dashboard

Go service for Proxmox homelab cluster provisioning. Handles PXE boot,
Proxmox autoinstall (answer file generation), cluster join via SSH,
and Infrastructure API registration.

- Host state machine (registered → pxe_ready → installing → ready)
- dnsmasq supervisor with MAC-based allowlist
- iPXE script and Proxmox answer file generation
- First-boot phone-home → cluster join → infra registration
- Operation locking with expiry (409 on conflict)
- SSE event hub for real-time dashboard updates
- Admin dashboard (host grid, detail, registration form)
- Config-driven server types with hot-reload
- Docker deployment (multi-stage fat image)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:55:14 -04:00
commit bda568b25c
39 changed files with 3067 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server Server `yaml:"server"`
Database Database `yaml:"database"`
PXE PXE `yaml:"pxe"`
Images Images `yaml:"images"`
Proxmox Proxmox `yaml:"proxmox"`
Credentials Credentials `yaml:"credentials"`
Infrastructure Infrastructure `yaml:"infrastructure"`
Locks Locks `yaml:"locks"`
ServerTypePath string `yaml:"server_types_path"`
}
type Server struct {
Bind string `yaml:"bind"`
PublicURL string `yaml:"public_url"`
}
type Database struct {
Path string `yaml:"path"`
}
type PXE struct {
Enabled bool `yaml:"enabled"`
Interface string `yaml:"interface"`
Subnet string `yaml:"subnet"`
RuntimeDir string `yaml:"runtime_dir"`
TFTPRoot string `yaml:"tftp_root"`
DnsmasqBin string `yaml:"dnsmasq_bin"`
}
type Images struct {
Dir string `yaml:"dir"`
}
type Proxmox struct {
ExistingNode string `yaml:"existing_node"`
ClusterName string `yaml:"cluster_name"`
JoinFingerprint string `yaml:"join_fingerprint"`
}
type Credentials struct {
SSHPrivateKeyPath string `yaml:"ssh_private_key_path"`
SSHPublicKey string `yaml:"ssh_public_key"`
RootPasswordHash string `yaml:"root_password_hash"`
}
type Infrastructure struct {
BaseURL string `yaml:"base_url"`
RoomID int `yaml:"room_id"`
ServerTypeMap map[string]int `yaml:"server_type_map"`
TimeoutSec int `yaml:"timeout_seconds"`
}
type Locks struct {
TTLMinutes int `yaml:"ttl_minutes"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
cfg := &Config{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
applyDefaults(cfg)
return cfg, nil
}
func applyDefaults(cfg *Config) {
if cfg.Server.Bind == "" {
cfg.Server.Bind = "0.0.0.0:8080"
}
if cfg.Database.Path == "" {
cfg.Database.Path = "./data/provisioning.db"
}
if cfg.PXE.RuntimeDir == "" {
cfg.PXE.RuntimeDir = "./data/pxe"
}
if cfg.PXE.TFTPRoot == "" {
cfg.PXE.TFTPRoot = "./data/tftp"
}
if cfg.PXE.DnsmasqBin == "" {
cfg.PXE.DnsmasqBin = "/usr/sbin/dnsmasq"
}
if cfg.Images.Dir == "" {
cfg.Images.Dir = "./data/images"
}
if cfg.Locks.TTLMinutes == 0 {
cfg.Locks.TTLMinutes = 60
}
if cfg.Infrastructure.TimeoutSec == 0 {
cfg.Infrastructure.TimeoutSec = 10
}
if cfg.ServerTypePath == "" {
cfg.ServerTypePath = "./server-types.yaml"
}
}