b23ef64ee1
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>
168 lines
4.3 KiB
Go
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))
|
|
}
|