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
+82
View File
@@ -0,0 +1,82 @@
package statemachine
import (
"fmt"
"provisioning/internal/model"
)
type Trigger string
const (
TriggerRebuildRequested Trigger = "RebuildRequested"
TriggerPXEScriptServed Trigger = "PXEScriptServed"
TriggerAnswerServed Trigger = "AnswerServed"
TriggerInstallWebhook Trigger = "InstallWebhook"
TriggerPhoneHome Trigger = "PhoneHome"
TriggerClusterJoinStart Trigger = "ClusterJoinStarted"
TriggerJoinComplete Trigger = "JoinComplete"
TriggerFailed Trigger = "Failed"
)
type transition struct {
from []model.HostState
to model.HostState
}
var allActiveStates = []model.HostState{
model.StatePXEReady,
model.StatePXEBooted,
model.StateInstalling,
model.StateInstalled,
model.StateFirstBoot,
model.StateJoining,
}
var table = map[Trigger]transition{
TriggerRebuildRequested: {
from: []model.HostState{model.StateRegistered, model.StateReady, model.StateFailed},
to: model.StatePXEReady,
},
TriggerPXEScriptServed: {
from: []model.HostState{model.StatePXEReady},
to: model.StatePXEBooted,
},
TriggerAnswerServed: {
from: []model.HostState{model.StatePXEBooted},
to: model.StateInstalling,
},
TriggerInstallWebhook: {
from: []model.HostState{model.StateInstalling},
to: model.StateInstalled,
},
TriggerPhoneHome: {
from: []model.HostState{model.StateInstalled},
to: model.StateFirstBoot,
},
TriggerClusterJoinStart: {
from: []model.HostState{model.StateFirstBoot},
to: model.StateJoining,
},
TriggerJoinComplete: {
from: []model.HostState{model.StateJoining},
to: model.StateReady,
},
TriggerFailed: {
from: allActiveStates,
to: model.StateFailed,
},
}
func Next(current model.HostState, t Trigger) (model.HostState, error) {
tr, ok := table[t]
if !ok {
return "", fmt.Errorf("unknown trigger %q", t)
}
for _, s := range tr.from {
if s == current {
return tr.to, nil
}
}
return "", fmt.Errorf("trigger %q not allowed from state %q", t, current)
}