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
+159
View File
@@ -0,0 +1,159 @@
package api
import (
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"provisioning/internal/config"
"provisioning/internal/events"
"provisioning/internal/model"
"provisioning/internal/orchestrator"
"provisioning/internal/pxe"
"provisioning/internal/statemachine"
"provisioning/internal/store"
"github.com/go-chi/chi/v5"
)
type UI struct {
Hosts *store.Hosts
Ops *store.Operations
Locks *store.Locks
Images *store.Images
Runner *orchestrator.Runner
Hub *events.Hub
PXE *pxe.Supervisor
Config *config.Config
ServerTypes *config.ServerTypeRegistry
}
func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
hosts, _ := u.Hosts.List(r.Context())
renderHTML(w, dashboardPage(hosts))
}
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, "", nil))
}
func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
hostname := strings.TrimSpace(r.FormValue("hostname"))
mac := strings.TrimSpace(r.FormValue("mac"))
serverType := r.FormValue("server_type")
notes := r.FormValue("notes")
var errs []string
if hostname == "" {
errs = append(errs, "Hostname is required")
}
if !isValidMAC(mac) {
errs = append(errs, "Invalid MAC address format (expected xx:xx:xx:xx:xx:xx)")
}
if _, ok := u.ServerTypes.Get(serverType); !ok {
errs = append(errs, "Invalid server type")
}
if len(errs) > 0 {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, strings.Join(errs, "; "), &model.Host{
Hostname: hostname,
MAC: mac,
ServerType: serverType,
Notes: notes,
}))
return
}
_, err := u.Hosts.Create(r.Context(), model.Host{
Hostname: hostname,
MAC: mac,
ServerType: serverType,
Notes: notes,
})
if err != nil {
types := u.ServerTypes.Keys()
renderHTML(w, hostFormPage(types, "Host already exists: "+err.Error(), nil))
return
}
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
host, err := u.Hosts.Get(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "Host not found", http.StatusNotFound)
return
}
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
ops, _ := u.Ops.ListByHost(r.Context(), host.ID)
renderHTML(w, hostDetailPage(host, ops))
}
func (u *UI) TriggerRebuild(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
host, err := u.Hosts.Get(r.Context(), id)
if err != nil {
http.Error(w, "Host not found", http.StatusNotFound)
return
}
locked, _ := u.Locks.IsLocked(r.Context(), host.ID)
if locked {
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
return
}
opID, _ := u.Ops.Create(r.Context(), model.Operation{
HostID: host.ID,
Kind: model.OpRebuildProxmox,
})
_ = u.Locks.Acquire(r.Context(), host.ID, opID)
u.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested)
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
}
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
var id int64
fmt.Sscanf(idStr, "%d", &id)
_ = u.Hosts.Delete(r.Context(), id)
hosts, _ := u.Hosts.List(r.Context())
_ = u.PXE.Reload(hosts)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
images, _ := u.Images.List(r.Context())
renderHTML(w, imagesPage(images))
}
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
func isValidMAC(mac string) bool {
return macRegex.MatchString(strings.TrimSpace(mac))
}