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)
}
+67
View File
@@ -0,0 +1,67 @@
package statemachine
import (
"testing"
"provisioning/internal/model"
)
func TestValidTransitions(t *testing.T) {
cases := []struct {
from model.HostState
trigger Trigger
want model.HostState
}{
{model.StateRegistered, TriggerRebuildRequested, model.StatePXEReady},
{model.StateReady, TriggerRebuildRequested, model.StatePXEReady},
{model.StateFailed, TriggerRebuildRequested, model.StatePXEReady},
{model.StatePXEReady, TriggerPXEScriptServed, model.StatePXEBooted},
{model.StatePXEBooted, TriggerAnswerServed, model.StateInstalling},
{model.StateInstalling, TriggerInstallWebhook, model.StateInstalled},
{model.StateInstalled, TriggerPhoneHome, model.StateFirstBoot},
{model.StateFirstBoot, TriggerClusterJoinStart, model.StateJoining},
{model.StateJoining, TriggerJoinComplete, model.StateReady},
}
for _, tc := range cases {
got, err := Next(tc.from, tc.trigger)
if err != nil {
t.Errorf("Next(%q, %q) error: %v", tc.from, tc.trigger, err)
continue
}
if got != tc.want {
t.Errorf("Next(%q, %q) = %q, want %q", tc.from, tc.trigger, got, tc.want)
}
}
}
func TestFailedFromAllActive(t *testing.T) {
for _, state := range allActiveStates {
got, err := Next(state, TriggerFailed)
if err != nil {
t.Errorf("Next(%q, Failed) error: %v", state, err)
continue
}
if got != model.StateFailed {
t.Errorf("Next(%q, Failed) = %q, want %q", state, got, model.StateFailed)
}
}
}
func TestInvalidTransitions(t *testing.T) {
cases := []struct {
from model.HostState
trigger Trigger
}{
{model.StateRegistered, TriggerPXEScriptServed},
{model.StateReady, TriggerPhoneHome},
{model.StatePXEReady, TriggerInstallWebhook},
{model.StateInstalling, TriggerRebuildRequested},
{model.StateRegistered, TriggerFailed},
}
for _, tc := range cases {
_, err := Next(tc.from, tc.trigger)
if err == nil {
t.Errorf("Next(%q, %q) expected error, got nil", tc.from, tc.trigger)
}
}
}