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,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})
|
||||
}
|
||||
Reference in New Issue
Block a user