b23ef64ee1
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>
249 lines
6.0 KiB
Go
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)
|
|
}
|