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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"provisioning/internal/model"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type serverTypesFile struct {
|
||||
ServerTypes map[string]model.ServerType `yaml:"server_types"`
|
||||
}
|
||||
|
||||
type ServerTypeRegistry struct {
|
||||
mu sync.RWMutex
|
||||
types map[string]model.ServerType
|
||||
path string
|
||||
}
|
||||
|
||||
func LoadServerTypes(path string) (*ServerTypeRegistry, error) {
|
||||
r := &ServerTypeRegistry{path: path}
|
||||
if err := r.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *ServerTypeRegistry) Get(key string) (model.ServerType, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
st, ok := r.types[key]
|
||||
return st, ok
|
||||
}
|
||||
|
||||
func (r *ServerTypeRegistry) List() []model.ServerType {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]model.ServerType, 0, len(r.types))
|
||||
for _, st := range r.types {
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ServerTypeRegistry) Keys() []string {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]string, 0, len(r.types))
|
||||
for k := range r.types {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *ServerTypeRegistry) load() error {
|
||||
data, err := os.ReadFile(r.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read server types: %w", err)
|
||||
}
|
||||
var f serverTypesFile
|
||||
if err := yaml.Unmarshal(data, &f); err != nil {
|
||||
return fmt.Errorf("parse server types: %w", err)
|
||||
}
|
||||
if len(f.ServerTypes) == 0 {
|
||||
return fmt.Errorf("server types file contains no types")
|
||||
}
|
||||
for k, st := range f.ServerTypes {
|
||||
st.Key = k
|
||||
f.ServerTypes[k] = st
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.types = f.ServerTypes
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ServerTypeRegistry) Watch(stop <-chan struct{}) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Printf("server-types: fsnotify unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
if err := watcher.Add(r.path); err != nil {
|
||||
log.Printf("server-types: watch failed: %v", err)
|
||||
_ = watcher.Close()
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case ev, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 {
|
||||
if err := r.load(); err != nil {
|
||||
log.Printf("server-types: hot-reload failed: %v (keeping previous config)", err)
|
||||
} else {
|
||||
log.Printf("server-types: reloaded %d types", len(r.types))
|
||||
}
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("server-types: watch error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user