Files
Provisioning/internal/api/smoke_test.go
T
josh b23ef64ee1
build-and-push / test (push) Successful in 9m57s
build-and-push / build-and-push (push) Has been cancelled
Use ephemeral SSH keys per rebuild instead of static config keys
Generate a fresh ed25519 key pair at rebuild time, inject the public key
into the Proxmox answer file, use the private key for cluster join over
SSH, then remove the key from both the remote host and the database.
This eliminates the need to manage static SSH keys in config/secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:09:22 -04:00

249 lines
6.0 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})
hostOrch := &orchestrator.HostOrchestrator{
Runner: runner,
Hosts: hosts,
Ops: ops,
Locks: locks,
Cluster: &orchestrator.ClusterJoiner{},
Config: cfg,
ServerTypes: serverTypes,
}
hostAPI := &api.HostAPI{
Hosts: hosts,
Ops: ops,
Locks: locks,
Images: images,
Runner: runner,
Orchestrator: hostOrch,
PXE: pxeSupervisor,
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,
Orchestrator: hostOrch,
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)
}