package api import ( "errors" "log" "net/http" "regexp" "strconv" "strings" "github.com/go-chi/chi/v5" "gopkg.in/yaml.v3" "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 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) 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 } }