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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user