Post-repair hardware validation pipeline for Proxmox cluster hosts. Go orchestrator + in-image agent + mkosi live image + bundled dnsmasq PXE + SQLite + HTMX/SSE UI + notify registry + janitor + full docs.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"vetting/internal/auth"
|
||||
"vetting/internal/events"
|
||||
"vetting/internal/model"
|
||||
"vetting/internal/orchestrator"
|
||||
"vetting/internal/store"
|
||||
"vetting/internal/web/templates"
|
||||
)
|
||||
|
||||
type UI struct {
|
||||
Hosts *store.Hosts
|
||||
Runs *store.Runs
|
||||
Artifacts *store.Artifacts
|
||||
Auth *auth.Manager
|
||||
EventHub *events.Hub
|
||||
Runner *orchestrator.Runner
|
||||
Tiles *TileEnricher
|
||||
}
|
||||
|
||||
var macRe = regexp.MustCompile(`^[0-9a-f]{2}(:[0-9a-f]{2}){5}$`)
|
||||
|
||||
func (u *UI) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
hosts, err := u.Hosts.List(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tiles := make([]templates.TileData, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
latest, err := u.Runs.LatestForHost(r.Context(), h.ID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tiles = append(tiles, u.Tiles.Build(r.Context(), h, latest))
|
||||
}
|
||||
_ = templates.Dashboard(tiles).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// StartRun creates a new Run for the host, issues an agent token, and
|
||||
// transitions Registered→Queued. The dispatcher goroutine picks it up
|
||||
// and fires WoL.
|
||||
func (u *UI) StartRun(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
hostID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := u.Hosts.Get(r.Context(), hostID); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Guard: refuse to start a second run while one is still active.
|
||||
if latest, err := u.Runs.LatestForHost(r.Context(), hostID); err == nil && latest != nil {
|
||||
switch latest.State {
|
||||
case model.StateCompleted, model.StateReleased, model.StateFailedHolding:
|
||||
// ok to start fresh
|
||||
default:
|
||||
http.Error(w, "host already has an active run", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, hash, err := orchestrator.IssueRunToken()
|
||||
if err != nil {
|
||||
http.Error(w, "token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runID, err := u.Runs.Create(r.Context(), hostID, hash)
|
||||
if err != nil {
|
||||
http.Error(w, "create run: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
log.Printf("ui: created run %d for host %d (state=Queued)", runID, hostID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) LoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
next := r.URL.Query().Get("next")
|
||||
if next == "" {
|
||||
next = "/"
|
||||
}
|
||||
_ = templates.Login("", next).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (u *UI) LoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
password := r.PostForm.Get("password")
|
||||
next := r.PostForm.Get("next")
|
||||
if next == "" || !strings.HasPrefix(next, "/") {
|
||||
next = "/"
|
||||
}
|
||||
if !u.Auth.VerifyPassword(password) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = templates.Login("Invalid password.", next).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
u.Auth.Issue(w, r)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
u.Auth.Clear(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) NewHostForm(w http.ResponseWriter, r *http.Request) {
|
||||
_ = templates.Registration(templates.RegistrationForm{}).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
func (u *UI) CreateHost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
form := templates.RegistrationForm{
|
||||
Name: strings.TrimSpace(r.PostForm.Get("name")),
|
||||
MAC: strings.ToLower(strings.TrimSpace(r.PostForm.Get("mac"))),
|
||||
WoLBroadcastIP: strings.TrimSpace(r.PostForm.Get("wol_broadcast_ip")),
|
||||
WoLPort: r.PostForm.Get("wol_port"),
|
||||
ExpectedSpecYAML: r.PostForm.Get("expected_spec_yaml"),
|
||||
Notes: strings.TrimSpace(r.PostForm.Get("notes")),
|
||||
}
|
||||
|
||||
if errMsg := validateHostForm(&form); errMsg != "" {
|
||||
form.Error = errMsg
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_ = templates.Registration(form).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
wolPort, _ := strconv.Atoi(form.WoLPort)
|
||||
if wolPort == 0 {
|
||||
wolPort = 9
|
||||
}
|
||||
|
||||
_, err := u.Hosts.Create(r.Context(), model.Host{
|
||||
Name: form.Name,
|
||||
MAC: form.MAC,
|
||||
WoLBroadcastIP: form.WoLBroadcastIP,
|
||||
WoLPort: wolPort,
|
||||
ExpectedSpecYAML: form.ExpectedSpecYAML,
|
||||
Notes: form.Notes,
|
||||
})
|
||||
if err != nil {
|
||||
form.Error = friendlyDBError(err)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_ = templates.Registration(form).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// OverrideWipeStorage is the operator's explicit "yes, wipe the disk
|
||||
// even though we found filesystem signatures" button. Only meaningful
|
||||
// when the latest run is FailedHolding with failed_stage=Storage — the
|
||||
// agent's next heartbeat will receive retry_stage with wipe=true and
|
||||
// re-enter the Storage stage bypassing the wipe-probe guard.
|
||||
func (u *UI) OverrideWipeStorage(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
hostID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad host id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
latest, err := u.Runs.LatestForHost(r.Context(), hostID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if latest == nil {
|
||||
http.Error(w, "no run for host", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if latest.State != model.StateFailedHolding || latest.FailedStage != "Storage" {
|
||||
http.Error(w, "override only valid when holding on Storage", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if _, err := u.Runner.Override(r.Context(), latest.ID, `{"wipe":true}`); err != nil {
|
||||
http.Error(w, "override: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) DeleteHost(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := u.Hosts.Delete(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UI) SSE(w http.ResponseWriter, r *http.Request) {
|
||||
u.EventHub.ServeSSE(w, r)
|
||||
}
|
||||
|
||||
// Report serves the HTML report artifact for a run. Looks up the
|
||||
// report_html artifact row for the runID, validates the path lives
|
||||
// under the artifacts dir (defence-in-depth against path traversal),
|
||||
// and streams it back. 404 when the run hasn't produced one yet.
|
||||
func (u *UI) Report(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "runID")
|
||||
runID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "bad run id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
arts, err := u.Artifacts.ListForRun(r.Context(), runID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var path string
|
||||
for _, a := range arts {
|
||||
if a.Kind == "report_html" {
|
||||
path = a.Path
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
func validateHostForm(form *templates.RegistrationForm) string {
|
||||
if form.Name == "" {
|
||||
return "Name is required."
|
||||
}
|
||||
if !macRe.MatchString(form.MAC) {
|
||||
return "MAC address must be in the form aa:bb:cc:dd:ee:ff."
|
||||
}
|
||||
if form.WoLBroadcastIP == "" {
|
||||
return "WoL broadcast IP is required."
|
||||
}
|
||||
if form.ExpectedSpecYAML == "" {
|
||||
return "Expected spec YAML is required."
|
||||
}
|
||||
var anything any
|
||||
if err := yaml.Unmarshal([]byte(form.ExpectedSpecYAML), &anything); err != nil {
|
||||
return "Expected spec YAML is not valid YAML: " + err.Error()
|
||||
}
|
||||
if form.WoLPort != "" {
|
||||
port, err := strconv.Atoi(form.WoLPort)
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
return "WoL port must be 1–65535."
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func friendlyDBError(err error) string {
|
||||
s := err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "UNIQUE constraint failed: hosts.name"):
|
||||
return "A host with that name already exists."
|
||||
case strings.Contains(s, "UNIQUE constraint failed: hosts.mac"):
|
||||
return "A host with that MAC already exists."
|
||||
default:
|
||||
return s
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user