Files
Provisioning/internal/api/smoke_test.go
T
josh bda568b25c 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>
2026-05-03 20:55:14 -04:00

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