Files
josh bda568b25c 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>
2026-05-03 20:55:14 -04:00

119 lines
2.4 KiB
Go

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)
}
}
}()
}