Files
Provisioning/internal/api/hosts.go
T
josh a6603b463f
build-and-push / test (push) Failing after 32s
build-and-push / build-and-push (push) Has been skipped
Add activity log system for provisioning lifecycle visibility
Hosts stuck in states like pxe_ready had zero visibility into why.
This adds a persistent activity log that records every meaningful
step (state transitions, PXE events, cluster join stages, failures)
and surfaces it on the host detail page with live SSE updates.
Includes a stuck-detection warning banner when a host sits in
pxe_ready for >10 minutes with no iPXE request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-13 23:30:21 -04:00

189 lines
4.8 KiB
Go

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
Orchestrator *orchestrator.HostOrchestrator
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
}
a.Runner.LogActivity(r.Context(), host.ID, model.LogInfo, "api", "Rebuild triggered via API")
if err := a.Orchestrator.PrepareRebuild(r.Context(), host.ID); err != nil {
_ = a.Locks.Release(r.Context(), host.ID)
writeJSONErr(w, http.StatusInternalServerError, "failed to generate SSH key: "+err.Error())
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})
}