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:
2026-05-03 20:55:14 -04:00
commit bda568b25c
39 changed files with 3067 additions and 0 deletions
+165
View File
@@ -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
}
+180
View File
@@ -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})
}
+185
View File
@@ -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)
}
+246
View File
@@ -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)
}
+159
View File
@@ -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))
}