Files
Provisioning/internal/api/ui.go
T
josh b23ef64ee1
build-and-push / test (push) Successful in 9m57s
build-and-push / build-and-push (push) Has been cancelled
Use ephemeral SSH keys per rebuild instead of static config keys
Generate a fresh ed25519 key pair at rebuild time, inject the public key
into the Proxmox answer file, use the private key for cluster join over
SSH, then remove the key from both the remote host and the database.
This eliminates the need to manage static SSH keys in config/secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-03 21:09:22 -04:00

168 lines
4.3 KiB
Go

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
Orchestrator *orchestrator.HostOrchestrator
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)
if err := u.Orchestrator.PrepareRebuild(r.Context(), host.ID); err != nil {
_ = u.Locks.Release(r.Context(), host.ID)
http.Error(w, "Failed to prepare rebuild: "+err.Error(), http.StatusInternalServerError)
return
}
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))
}