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>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"provisioning/internal/config"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
"provisioning/internal/statemachine"
|
||||
"provisioning/internal/store"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type BootAPI struct {
|
||||
Hosts *store.Hosts
|
||||
Images *store.Images
|
||||
Runner *orchestrator.Runner
|
||||
Orchestrator *orchestrator.HostOrchestrator
|
||||
Config *config.Config
|
||||
ServerTypes *config.ServerTypeRegistry
|
||||
}
|
||||
|
||||
func (a *BootAPI) IPXEScript(w http.ResponseWriter, r *http.Request) {
|
||||
mac := normalizeMAC(chi.URLParam(r, "mac"))
|
||||
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "#!ipxe\nexit", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
img, err := a.Images.GetDefault(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "#!ipxe\necho No default image configured\nshell", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if host.State == model.StatePXEReady {
|
||||
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerPXEScriptServed)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte(pxe.BuildIPXEScript(a.Config.Server.PublicURL, img, mac)))
|
||||
}
|
||||
|
||||
func (a *BootAPI) AnswerFile(w http.ResponseWriter, r *http.Request) {
|
||||
var sysInfo struct {
|
||||
MAC string `json:"mac"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&sysInfo); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
mac := normalizeMAC(sysInfo.MAC)
|
||||
host, err := a.Hosts.GetByMAC(r.Context(), mac)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "unknown host", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
st, ok := a.ServerTypes.Get(host.ServerType)
|
||||
if !ok {
|
||||
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if host.State == model.StatePXEBooted {
|
||||
a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerAnswerServed)
|
||||
}
|
||||
|
||||
answer := pxe.GenerateAnswerFile(host, st, a.Config)
|
||||
w.Header().Set("Content-Type", "application/toml")
|
||||
w.Write([]byte(answer))
|
||||
}
|
||||
|
||||
func (a *BootAPI) InstallComplete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := idFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
host, err := a.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||
return
|
||||
}
|
||||
|
||||
if host.State == model.StateInstalling {
|
||||
if _, err := a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerInstallWebhook); err != nil {
|
||||
log.Printf("host %d: install-complete transition failed: %v", host.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *BootAPI) FirstBootScript(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := idFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
host, err := a.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "host not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
st, ok := a.ServerTypes.Get(host.ServerType)
|
||||
if !ok {
|
||||
http.Error(w, "unknown server type", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
script := pxe.GenerateFirstBootScript(host, st, a.Config)
|
||||
w.Header().Set("Content-Type", "text/x-shellscript")
|
||||
w.Write([]byte(script))
|
||||
}
|
||||
|
||||
func (a *BootAPI) PhoneHome(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := idFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IP string `json:"ip"`
|
||||
HardwareID string `json:"hardware_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONErr(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
|
||||
host, err := a.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("host %d (%s): phone-home from %s, hwid=%s", host.ID, host.Hostname, req.IP, req.HardwareID)
|
||||
a.Orchestrator.HandlePhoneHome(r.Context(), host.ID, req.IP, req.HardwareID)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func normalizeMAC(m string) string {
|
||||
m = strings.ToLower(strings.TrimSpace(m))
|
||||
m = strings.ReplaceAll(m, "-", ":")
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"provisioning/internal/config"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
"provisioning/internal/statemachine"
|
||||
"provisioning/internal/store"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type HostAPI struct {
|
||||
Hosts *store.Hosts
|
||||
Ops *store.Operations
|
||||
Locks *store.Locks
|
||||
Images *store.Images
|
||||
Runner *orchestrator.Runner
|
||||
PXE *pxe.Supervisor
|
||||
Config *config.Config
|
||||
ServerTypes *config.ServerTypeRegistry
|
||||
}
|
||||
|
||||
func (a *HostAPI) List(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := a.Hosts.List(r.Context())
|
||||
if err != nil {
|
||||
writeJSONErr(w, http.StatusInternalServerError, "failed to list hosts")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hosts)
|
||||
}
|
||||
|
||||
func (a *HostAPI) Get(w http.ResponseWriter, r *http.Request) {
|
||||
host, ok := a.hostFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, host)
|
||||
}
|
||||
|
||||
func (a *HostAPI) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Hostname string `json:"hostname"`
|
||||
MAC string `json:"mac"`
|
||||
ServerType string `json:"server_type"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONErr(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if req.Hostname == "" || req.MAC == "" || req.ServerType == "" {
|
||||
writeJSONErr(w, http.StatusBadRequest, "hostname, mac, and server_type are required")
|
||||
return
|
||||
}
|
||||
if _, ok := a.ServerTypes.Get(req.ServerType); !ok {
|
||||
writeJSONErr(w, http.StatusBadRequest, "unknown server_type")
|
||||
return
|
||||
}
|
||||
|
||||
id, err := a.Hosts.Create(r.Context(), model.Host{
|
||||
Hostname: req.Hostname,
|
||||
MAC: req.MAC,
|
||||
ServerType: req.ServerType,
|
||||
Notes: req.Notes,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSONErr(w, http.StatusConflict, "host already exists: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.reloadPXE()
|
||||
|
||||
host, _ := a.Hosts.Get(r.Context(), id)
|
||||
writeJSON(w, http.StatusCreated, host)
|
||||
}
|
||||
|
||||
func (a *HostAPI) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := idFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.Hosts.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||
return
|
||||
}
|
||||
writeJSONErr(w, http.StatusInternalServerError, "failed to delete host")
|
||||
return
|
||||
}
|
||||
a.reloadPXE()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *HostAPI) Rebuild(w http.ResponseWriter, r *http.Request) {
|
||||
host, ok := a.hostFromURL(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
locked, _ := a.Locks.IsLocked(r.Context(), host.ID)
|
||||
if locked {
|
||||
writeJSONErr(w, http.StatusConflict, "host is locked by another operation")
|
||||
return
|
||||
}
|
||||
|
||||
opID, err := a.Ops.Create(r.Context(), model.Operation{
|
||||
HostID: host.ID,
|
||||
Kind: model.OpRebuildProxmox,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSONErr(w, http.StatusInternalServerError, "failed to create operation")
|
||||
return
|
||||
}
|
||||
if err := a.Locks.Acquire(r.Context(), host.ID, opID); err != nil {
|
||||
writeJSONErr(w, http.StatusInternalServerError, "failed to acquire lock")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := a.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested); err != nil {
|
||||
_ = a.Locks.Release(r.Context(), host.ID)
|
||||
writeJSONErr(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.reloadPXE()
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "operation_id": opID})
|
||||
}
|
||||
|
||||
func (a *HostAPI) hostFromURL(w http.ResponseWriter, r *http.Request) (*model.Host, bool) {
|
||||
id, ok := idFromURL(w, r)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
host, err := a.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeJSONErr(w, http.StatusNotFound, "host not found")
|
||||
} else {
|
||||
writeJSONErr(w, http.StatusInternalServerError, "failed to get host")
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return host, true
|
||||
}
|
||||
|
||||
func (a *HostAPI) reloadPXE() {
|
||||
if a.PXE == nil {
|
||||
return
|
||||
}
|
||||
hosts, _ := a.Hosts.List(context.Background())
|
||||
_ = a.PXE.Reload(hosts)
|
||||
}
|
||||
|
||||
func idFromURL(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
s := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
writeJSONErr(w, http.StatusBadRequest, "invalid id")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func writeJSONErr(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]any{"ok": false, "error": msg})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"provisioning/internal/model"
|
||||
)
|
||||
|
||||
func renderHTML(w http.ResponseWriter, body string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func dashboardPage(hosts []model.Host) string {
|
||||
var tiles strings.Builder
|
||||
for _, h := range hosts {
|
||||
tiles.WriteString(hostTile(h))
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
tiles.WriteString(`<p class="empty">No hosts registered. <a href="/hosts/new">Register one.</a></p>`)
|
||||
}
|
||||
return layout("Dashboard", fmt.Sprintf(`
|
||||
<div class="actions">
|
||||
<a href="/hosts/new" class="btn">Register Host</a>
|
||||
<span class="count">%d hosts</span>
|
||||
</div>
|
||||
<div class="host-grid">%s</div>
|
||||
`, len(hosts), tiles.String()))
|
||||
}
|
||||
|
||||
func hostTile(h model.Host) string {
|
||||
stateClass := stateColor(h.State)
|
||||
return fmt.Sprintf(`
|
||||
<a href="/hosts/%d" class="tile %s" id="tile-%d">
|
||||
<div class="tile-name">%s</div>
|
||||
<div class="tile-type">%s</div>
|
||||
<div class="tile-state">%s</div>
|
||||
<div class="tile-mac">%s</div>
|
||||
</a>
|
||||
`, h.ID, stateClass, h.ID, html.EscapeString(h.Hostname), html.EscapeString(h.ServerType), h.State, h.MAC)
|
||||
}
|
||||
|
||||
func hostFormPage(types []string, errMsg string, prefill *model.Host) string {
|
||||
var opts strings.Builder
|
||||
for _, t := range types {
|
||||
selected := ""
|
||||
if prefill != nil && prefill.ServerType == t {
|
||||
selected = " selected"
|
||||
}
|
||||
opts.WriteString(fmt.Sprintf(`<option value="%s"%s>%s</option>`, t, selected, t))
|
||||
}
|
||||
errHTML := ""
|
||||
if errMsg != "" {
|
||||
errHTML = fmt.Sprintf(`<div class="error">%s</div>`, html.EscapeString(errMsg))
|
||||
}
|
||||
hostname, mac, notes := "", "", ""
|
||||
if prefill != nil {
|
||||
hostname = html.EscapeString(prefill.Hostname)
|
||||
mac = html.EscapeString(prefill.MAC)
|
||||
notes = html.EscapeString(prefill.Notes)
|
||||
}
|
||||
return layout("Register Host", fmt.Sprintf(`
|
||||
<h2>Register Host</h2>
|
||||
%s
|
||||
<form method="POST" action="/hosts" class="form">
|
||||
<label>Hostname<input type="text" name="hostname" value="%s" required></label>
|
||||
<label>MAC Address<input type="text" name="mac" value="%s" placeholder="aa:bb:cc:dd:ee:ff" required></label>
|
||||
<label>Server Type<select name="server_type" required>%s</select></label>
|
||||
<label>Notes<textarea name="notes">%s</textarea></label>
|
||||
<button type="submit" class="btn">Register</button>
|
||||
</form>
|
||||
`, errHTML, hostname, mac, opts.String(), notes))
|
||||
}
|
||||
|
||||
func hostDetailPage(h *model.Host, ops []model.Operation) string {
|
||||
stateClass := stateColor(h.State)
|
||||
canRebuild := h.State == model.StateRegistered || h.State == model.StateReady || h.State == model.StateFailed
|
||||
|
||||
var actions strings.Builder
|
||||
if canRebuild {
|
||||
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/rebuild" class="inline"><button class="btn">Rebuild with Proxmox</button></form>`, h.ID))
|
||||
}
|
||||
actions.WriteString(fmt.Sprintf(`<form method="POST" action="/hosts/%d/delete" class="inline" onsubmit="return confirm('Delete this host?')"><button class="btn btn-danger">Delete</button></form>`, h.ID))
|
||||
|
||||
var opsHTML strings.Builder
|
||||
for _, op := range ops {
|
||||
duration := ""
|
||||
if op.CompletedAt != nil {
|
||||
duration = op.CompletedAt.Sub(op.StartedAt).Truncate(1e9).String()
|
||||
}
|
||||
errCell := ""
|
||||
if op.ErrorMessage != "" {
|
||||
errCell = html.EscapeString(op.ErrorMessage)
|
||||
}
|
||||
opsHTML.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
op.Kind, op.State, op.StartedAt.Format("2006-01-02 15:04"), duration, errCell))
|
||||
}
|
||||
|
||||
ip := h.IPAddress
|
||||
if ip == "" {
|
||||
ip = "—"
|
||||
}
|
||||
|
||||
return layout(h.Hostname, fmt.Sprintf(`
|
||||
<div class="host-header">
|
||||
<h2>%s</h2>
|
||||
<span class="badge %s">%s</span>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tr><th>MAC</th><td>%s</td></tr>
|
||||
<tr><th>Server Type</th><td>%s</td></tr>
|
||||
<tr><th>IP Address</th><td>%s</td></tr>
|
||||
<tr><th>Notes</th><td>%s</td></tr>
|
||||
</table>
|
||||
<div class="actions">%s</div>
|
||||
<h3>Operations</h3>
|
||||
<table class="ops-table">
|
||||
<thead><tr><th>Kind</th><th>State</th><th>Started</th><th>Duration</th><th>Error</th></tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
`, html.EscapeString(h.Hostname), stateClass, h.State, h.MAC, h.ServerType, ip, html.EscapeString(h.Notes), actions.String(), opsHTML.String()))
|
||||
}
|
||||
|
||||
func imagesPage(images []model.Image) string {
|
||||
var rows strings.Builder
|
||||
for _, img := range images {
|
||||
def := ""
|
||||
if img.IsDefault {
|
||||
def = "✓"
|
||||
}
|
||||
rows.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
|
||||
html.EscapeString(img.Name), img.Kind, img.Version, def, img.CreatedAt.Format("2006-01-02")))
|
||||
}
|
||||
return layout("Images", fmt.Sprintf(`
|
||||
<h2>Boot Images</h2>
|
||||
<table class="ops-table">
|
||||
<thead><tr><th>Name</th><th>Kind</th><th>Version</th><th>Default</th><th>Added</th></tr></thead>
|
||||
<tbody>%s</tbody>
|
||||
</table>
|
||||
`, rows.String()))
|
||||
}
|
||||
|
||||
func stateColor(s model.HostState) string {
|
||||
switch s {
|
||||
case model.StateRegistered:
|
||||
return "state-grey"
|
||||
case model.StatePXEReady, model.StatePXEBooted, model.StateInstalling:
|
||||
return "state-blue"
|
||||
case model.StateInstalled, model.StateFirstBoot, model.StateJoining:
|
||||
return "state-amber"
|
||||
case model.StateReady:
|
||||
return "state-green"
|
||||
case model.StateFailed:
|
||||
return "state-red"
|
||||
default:
|
||||
return "state-grey"
|
||||
}
|
||||
}
|
||||
|
||||
func layout(title, body string) string {
|
||||
return fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>%s — Provisioning</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="topbar">
|
||||
<a href="/" class="brand">Provisioning</a>
|
||||
<div class="nav-links">
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/images">Images</a>
|
||||
</div>
|
||||
<span class="sse-indicator" id="sse-dot">●</span>
|
||||
</nav>
|
||||
<main>%s</main>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>`, html.EscapeString(title), body)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"provisioning/internal/config"
|
||||
"provisioning/internal/events"
|
||||
"provisioning/internal/model"
|
||||
"provisioning/internal/orchestrator"
|
||||
"provisioning/internal/pxe"
|
||||
"provisioning/internal/statemachine"
|
||||
"provisioning/internal/store"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Ops *store.Operations
|
||||
Locks *store.Locks
|
||||
Images *store.Images
|
||||
Runner *orchestrator.Runner
|
||||
Hub *events.Hub
|
||||
PXE *pxe.Supervisor
|
||||
Config *config.Config
|
||||
ServerTypes *config.ServerTypeRegistry
|
||||
}
|
||||
|
||||
func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, _ := u.Hosts.List(r.Context())
|
||||
renderHTML(w, dashboardPage(hosts))
|
||||
}
|
||||
|
||||
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||
types := u.ServerTypes.Keys()
|
||||
renderHTML(w, hostFormPage(types, "", nil))
|
||||
}
|
||||
|
||||
func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hostname := strings.TrimSpace(r.FormValue("hostname"))
|
||||
mac := strings.TrimSpace(r.FormValue("mac"))
|
||||
serverType := r.FormValue("server_type")
|
||||
notes := r.FormValue("notes")
|
||||
|
||||
var errs []string
|
||||
if hostname == "" {
|
||||
errs = append(errs, "Hostname is required")
|
||||
}
|
||||
if !isValidMAC(mac) {
|
||||
errs = append(errs, "Invalid MAC address format (expected xx:xx:xx:xx:xx:xx)")
|
||||
}
|
||||
if _, ok := u.ServerTypes.Get(serverType); !ok {
|
||||
errs = append(errs, "Invalid server type")
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
types := u.ServerTypes.Keys()
|
||||
renderHTML(w, hostFormPage(types, strings.Join(errs, "; "), &model.Host{
|
||||
Hostname: hostname,
|
||||
MAC: mac,
|
||||
ServerType: serverType,
|
||||
Notes: notes,
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
_, err := u.Hosts.Create(r.Context(), model.Host{
|
||||
Hostname: hostname,
|
||||
MAC: mac,
|
||||
ServerType: serverType,
|
||||
Notes: notes,
|
||||
})
|
||||
if err != nil {
|
||||
types := u.ServerTypes.Keys()
|
||||
renderHTML(w, hostFormPage(types, "Host already exists: "+err.Error(), nil))
|
||||
return
|
||||
}
|
||||
|
||||
hosts, _ := u.Hosts.List(r.Context())
|
||||
_ = u.PXE.Reload(hosts)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) HostDetail(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
var id int64
|
||||
fmt.Sscanf(idStr, "%d", &id)
|
||||
|
||||
host, err := u.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.Error(w, "Host not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
ops, _ := u.Ops.ListByHost(r.Context(), host.ID)
|
||||
renderHTML(w, hostDetailPage(host, ops))
|
||||
}
|
||||
|
||||
func (u *UI) TriggerRebuild(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
var id int64
|
||||
fmt.Sscanf(idStr, "%d", &id)
|
||||
|
||||
host, err := u.Hosts.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "Host not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
locked, _ := u.Locks.IsLocked(r.Context(), host.ID)
|
||||
if locked {
|
||||
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
opID, _ := u.Ops.Create(r.Context(), model.Operation{
|
||||
HostID: host.ID,
|
||||
Kind: model.OpRebuildProxmox,
|
||||
})
|
||||
_ = u.Locks.Acquire(r.Context(), host.ID, opID)
|
||||
u.Runner.Transition(r.Context(), host.ID, statemachine.TriggerRebuildRequested)
|
||||
|
||||
hosts, _ := u.Hosts.List(r.Context())
|
||||
_ = u.PXE.Reload(hosts)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/hosts/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
var id int64
|
||||
fmt.Sscanf(idStr, "%d", &id)
|
||||
|
||||
_ = u.Hosts.Delete(r.Context(), id)
|
||||
hosts, _ := u.Hosts.List(r.Context())
|
||||
_ = u.PXE.Reload(hosts)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) ImagesPage(w http.ResponseWriter, r *http.Request) {
|
||||
images, _ := u.Images.List(r.Context())
|
||||
renderHTML(w, imagesPage(images))
|
||||
}
|
||||
|
||||
var macRegex = regexp.MustCompile(`^([0-9a-fA-F]{2}[:\-]){5}[0-9a-fA-F]{2}$`)
|
||||
|
||||
func isValidMAC(mac string) bool {
|
||||
return macRegex.MatchString(strings.TrimSpace(mac))
|
||||
}
|
||||
Reference in New Issue
Block a user