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,246 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"provisioning/internal/api"
|
||||
"provisioning/internal/config"
|
||||
"provisioning/internal/db"
|
||||
"provisioning/internal/events"
|
||||
"provisioning/internal/httpserver"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
"provisioning/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
tmp := t.TempDir()
|
||||
|
||||
database, err := db.Open(filepath.Join(tmp, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { database.Close() })
|
||||
|
||||
hosts := &store.Hosts{DB: database}
|
||||
ops := &store.Operations{DB: database}
|
||||
locks := &store.Locks{DB: database, TTLMinutes: 60}
|
||||
images := &store.Images{DB: database}
|
||||
hub := events.NewHub()
|
||||
t.Cleanup(func() { hub.Shutdown(context.Background()) })
|
||||
|
||||
cfg := &config.Config{
|
||||
Server: config.Server{
|
||||
Bind: "127.0.0.1:0",
|
||||
PublicURL: "http://localhost:8080",
|
||||
},
|
||||
Locks: config.Locks{TTLMinutes: 60},
|
||||
}
|
||||
|
||||
serverTypes := mustLoadServerTypes(t, tmp)
|
||||
|
||||
runner := &orchestrator.Runner{
|
||||
Hosts: hosts,
|
||||
Ops: ops,
|
||||
Locks: locks,
|
||||
Hub: hub,
|
||||
}
|
||||
|
||||
pxeSupervisor := pxe.NewSupervisor(pxe.SupervisorConfig{Enabled: false})
|
||||
|
||||
hostAPI := &api.HostAPI{
|
||||
Hosts: hosts,
|
||||
Ops: ops,
|
||||
Locks: locks,
|
||||
Images: images,
|
||||
Runner: runner,
|
||||
PXE: pxeSupervisor,
|
||||
Config: cfg,
|
||||
ServerTypes: serverTypes,
|
||||
}
|
||||
|
||||
hostOrch := &orchestrator.HostOrchestrator{
|
||||
Runner: runner,
|
||||
Hosts: hosts,
|
||||
Ops: ops,
|
||||
Locks: locks,
|
||||
Cluster: &orchestrator.ClusterJoiner{},
|
||||
Config: cfg,
|
||||
ServerTypes: serverTypes,
|
||||
}
|
||||
|
||||
bootAPI := &api.BootAPI{
|
||||
Hosts: hosts,
|
||||
Images: images,
|
||||
Runner: runner,
|
||||
Orchestrator: hostOrch,
|
||||
Config: cfg,
|
||||
ServerTypes: serverTypes,
|
||||
}
|
||||
|
||||
ui := &api.UI{
|
||||
Hosts: hosts,
|
||||
Ops: ops,
|
||||
Locks: locks,
|
||||
Images: images,
|
||||
Runner: runner,
|
||||
Hub: hub,
|
||||
PXE: pxeSupervisor,
|
||||
Config: cfg,
|
||||
ServerTypes: serverTypes,
|
||||
}
|
||||
|
||||
router := httpserver.NewRouter(httpserver.Deps{
|
||||
HostAPI: hostAPI,
|
||||
BootAPI: bootAPI,
|
||||
UI: ui,
|
||||
Hub: hub,
|
||||
})
|
||||
|
||||
return httptest.NewServer(router)
|
||||
}
|
||||
|
||||
func mustLoadServerTypes(t *testing.T, dir string) *config.ServerTypeRegistry {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "server-types.yaml")
|
||||
content := []byte(`server_types:
|
||||
test-type:
|
||||
display_name: "Test Type"
|
||||
boot_disk: "/dev/sda"
|
||||
management_nic: "eth0"
|
||||
gpu: false
|
||||
hostname_prefix: "pve-test"
|
||||
`)
|
||||
if err := writeTestFile(path, content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := config.LoadServerTypes(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
func writeTestFile(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o644)
|
||||
}
|
||||
|
||||
func TestCreateAndListHosts(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Create host via JSON API
|
||||
body := `{"hostname":"pve-test-01","mac":"aa:bb:cc:dd:ee:01","server_type":"test-type"}`
|
||||
resp, err := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: got %d, want %d", resp.StatusCode, http.StatusCreated)
|
||||
}
|
||||
|
||||
var created model.Host
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
if created.Hostname != "pve-test-01" {
|
||||
t.Fatalf("hostname = %q, want %q", created.Hostname, "pve-test-01")
|
||||
}
|
||||
if created.State != model.StateRegistered {
|
||||
t.Fatalf("state = %q, want %q", created.State, model.StateRegistered)
|
||||
}
|
||||
|
||||
// List hosts
|
||||
resp, err = http.Get(ts.URL + "/api/hosts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hosts []model.Host
|
||||
json.NewDecoder(resp.Body).Decode(&hosts)
|
||||
resp.Body.Close()
|
||||
if len(hosts) != 1 {
|
||||
t.Fatalf("list: got %d hosts, want 1", len(hosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildTransition(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
// Create host
|
||||
body := `{"hostname":"pve-test-02","mac":"aa:bb:cc:dd:ee:02","server_type":"test-type"}`
|
||||
resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||
var created model.Host
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
// Trigger rebuild
|
||||
resp, err := http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rebuild: got %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify state is pxe_ready
|
||||
resp, _ = http.Get(ts.URL + "/api/hosts/" + itoa(created.ID))
|
||||
var host model.Host
|
||||
json.NewDecoder(resp.Body).Decode(&host)
|
||||
resp.Body.Close()
|
||||
if host.State != model.StatePXEReady {
|
||||
t.Fatalf("state = %q, want %q", host.State, model.StatePXEReady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateRebuildConflict(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
body := `{"hostname":"pve-test-03","mac":"aa:bb:cc:dd:ee:03","server_type":"test-type"}`
|
||||
resp, _ := http.Post(ts.URL+"/api/hosts", "application/json", bytes.NewBufferString(body))
|
||||
var created model.Host
|
||||
json.NewDecoder(resp.Body).Decode(&created)
|
||||
resp.Body.Close()
|
||||
|
||||
// First rebuild
|
||||
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||
resp.Body.Close()
|
||||
|
||||
// Second rebuild should 409
|
||||
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("second rebuild: got %d, want %d", resp.StatusCode, http.StatusConflict)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestDashboardHTML(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("dashboard: got %d", resp.StatusCode)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
||||
t.Fatalf("content-type = %q", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int64) string {
|
||||
return fmt.Sprintf("%d", i)
|
||||
}
|
||||
Reference in New Issue
Block a user