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