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,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