1317ff6369
Operations are now clickable from the host detail page, linking to
/ops/{id} which shows the operation info, host link, duration, and
activity log filtered to that operation. Active operations can be
cancelled, which transitions the host to failed and releases the lock.
SSE activity events now include operation_id for real-time filtering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
8.8 KiB
Go
343 lines
8.8 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/image"
|
|
"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}
|
|
activity := &store.Activity{DB: database}
|
|
hub := events.NewHub()
|
|
t.Cleanup(func() { hub.Shutdown(context.Background()) })
|
|
|
|
imageDir := filepath.Join(tmp, "images")
|
|
os.MkdirAll(imageDir, 0o755)
|
|
imageSvc := &image.Service{Store: images, ImageDir: imageDir}
|
|
|
|
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,
|
|
Activity: activity,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
imageAPI := &api.ImageAPI{Svc: imageSvc}
|
|
|
|
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,
|
|
Activity: activity,
|
|
ImageSvc: imageSvc,
|
|
Runner: runner,
|
|
Orchestrator: hostOrch,
|
|
Hub: hub,
|
|
PXE: pxeSupervisor,
|
|
Config: cfg,
|
|
ServerTypes: serverTypes,
|
|
}
|
|
|
|
router := httpserver.NewRouter(httpserver.Deps{
|
|
HostAPI: hostAPI,
|
|
BootAPI: bootAPI,
|
|
ImageAPI: imageAPI,
|
|
UI: ui,
|
|
Hub: hub,
|
|
ImageDir: imageDir,
|
|
})
|
|
|
|
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 TestOperationDetailPage(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
body := `{"hostname":"pve-test-op","mac":"aa:bb:cc:dd:ee:10","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 to create an operation
|
|
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
|
resp.Body.Close()
|
|
|
|
// Get operation detail page
|
|
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}
|
|
resp, err := client.Get(ts.URL + "/ops/1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("op detail: got %d, want %d", resp.StatusCode, http.StatusOK)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "text/html; charset=utf-8" {
|
|
t.Fatalf("content-type = %q", ct)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func TestCancelOperation(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
body := `{"hostname":"pve-test-cancel","mac":"aa:bb:cc:dd:ee:11","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()
|
|
|
|
resp, _ = http.Post(ts.URL+"/api/hosts/"+itoa(created.ID)+"/rebuild", "application/json", nil)
|
|
resp.Body.Close()
|
|
|
|
// Cancel the operation
|
|
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}}
|
|
resp, err := client.Post(ts.URL+"/ops/1/cancel", "", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
t.Fatalf("cancel: got %d, want %d", resp.StatusCode, http.StatusSeeOther)
|
|
}
|
|
resp.Body.Close()
|
|
|
|
// Verify host is now failed
|
|
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.StateFailed {
|
|
t.Fatalf("state = %q, want %q", host.State, model.StateFailed)
|
|
}
|
|
}
|
|
|
|
func TestOperationNotFound(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
defer ts.Close()
|
|
|
|
resp, err := http.Get(ts.URL + "/ops/99999")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("op not found: got %d, want %d", resp.StatusCode, http.StatusNotFound)
|
|
}
|
|
resp.Body.Close()
|
|
}
|
|
|
|
func itoa(i int64) string {
|
|
return fmt.Sprintf("%d", i)
|
|
}
|