bda568b25c
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>
247 lines
5.9 KiB
Go
247 lines
5.9 KiB
Go
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)
|
|
}
|