Pulse/internal/api/maintenance_verification.go
rcourtman b707512e38 Clear all errcheck and gofmt violations so make lint gates on real findings
golangci-lint run ./... failed on ~190 pre-existing errcheck violations and
5 unformatted files, burying any new regression in noise. Fix all of them:

- Test files that hand-rolled mock-mode set/restore (vmware, truenas, and
  friends) now use the canonical setMockModeForTest/testutil.SetMockMode
  helper instead of drift copies that ignored SetEnabled errors.
- internal/mock and internal/monitoring tests get package-local
  mustSetEnabled/mustSetMockEnabled/mustSetMonitorMockMode helpers that
  fail the test on toggle errors.
- pkg/auth/sqlite_manager.go, pkg/metrics/store.go, pkg/server/server.go:
  rollbacks in defers use the explicit-discard idiom, migration renames and
  rollup commits log failures, the hosted reaper goroutine logs an error
  exit, shutdown mock-disable logs failures.
- Remaining test sites check errors with t.Fatalf/t.Errorf or explicitly
  discard best-effort calls (restore-chmods, handler-closure unmarshals)
  per existing repo style.
- gofmt: internal/api/maintenance_verification.go, internal/ai/demo.go and
  three findings test files.

Only dupl findings remain (44 pre-existing production-code duplication
pairs) — those need real refactors, not mechanical fixes.

Full test suites pass for every touched package.
2026-06-09 21:42:21 +01:00

372 lines
15 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/maintenancesentinel"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// maintenanceVerificationReportAPI is the wire shape for Maintenance
// Verification Reports. Mirrors `unified.LoopReport` but is kept as a
// separate API type so the storage shape can evolve independently of
// the wire contract. Filenames and product copy use the "Maintenance
// Verification Report" name; the underlying loop substrate is
// implementation detail and is not exposed verbatim.
type maintenanceVerificationReportAPI struct {
ID string `json:"id"`
ResourceID string `json:"resourceId"`
Trigger string `json:"trigger"`
Goal string `json:"goal,omitempty"`
Status string `json:"status"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
WindowStartedAt *time.Time `json:"windowStartedAt,omitempty"`
WindowEndedAt *time.Time `json:"windowEndedAt,omitempty"`
Evidence maintenanceVerificationEvidenceAPI `json:"evidence"`
LinkedFindingIDs []string `json:"linkedFindingIds"`
LinkedAlertIDs []string `json:"linkedAlertIds"`
LinkedActionIDs []string `json:"linkedActionIds"`
LinkedPatrolRunID string `json:"linkedPatrolRunId,omitempty"`
Recommendation string `json:"recommendation,omitempty"`
UserOutcome string `json:"userOutcome,omitempty"`
ReviewedAt *time.Time `json:"reviewedAt,omitempty"`
ReviewedBy string `json:"reviewedBy,omitempty"`
ReviewNote string `json:"reviewNote,omitempty"`
}
type maintenanceVerificationEvidenceAPI struct {
OperatorStateSummary string `json:"operatorStateSummary,omitempty"`
ActiveCriticalAlerts int `json:"activeCriticalAlerts"`
ActiveWarningAlerts int `json:"activeWarningAlerts"`
ActiveCriticalFindings int `json:"activeCriticalFindings"`
ActiveWarningFindings int `json:"activeWarningFindings"`
FailedActionsSinceWindowStart int `json:"failedActionsSinceWindowStart"`
MetricRecovery *maintenanceVerificationMetricRecoveryAPI `json:"metricRecovery,omitempty"`
PatrolRunTODO string `json:"patrolRunTodo,omitempty"`
}
type maintenanceVerificationMetricRecoveryAPI struct {
MetricsObserved []string `json:"metricsObserved,omitempty"`
SamplesAfterEnd int `json:"samplesAfterEnd"`
Trend string `json:"trend,omitempty"`
Note string `json:"note,omitempty"`
}
type maintenanceVerificationReviewRequest struct {
Note string `json:"note,omitempty"`
}
// MaintenanceVerificationHandlers serves the Maintenance Verification
// Report endpoints. Composes the resource store (for read/write of
// LoopReport rows) with the sentinel runtime (for "rerun" requests).
type MaintenanceVerificationHandlers struct {
resources *ResourceHandlers
sentinel *maintenancesentinel.Sentinel
}
// NewMaintenanceVerificationHandlers wires the handler. resources is
// required (it owns the per-org store accessor); sentinel may be nil
// when the runtime has not started a sentinel (e.g. unit tests of the
// other endpoints) — in that case the rerun endpoint returns 503.
func NewMaintenanceVerificationHandlers(resources *ResourceHandlers, sentinel *maintenancesentinel.Sentinel) *MaintenanceVerificationHandlers {
return &MaintenanceVerificationHandlers{resources: resources, sentinel: sentinel}
}
// HandleListForResource serves
// `GET /api/resources/{id}/maintenance-verifications?limit=N`. Returns
// the most recent reports for the resource. Limit defaults to 25 and
// is capped at 200.
func (h *MaintenanceVerificationHandlers) HandleListForResource(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.resources == nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
resourceID := extractMaintenanceVerificationsResourceID(r.URL.Path)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
orgID := GetOrgID(r.Context())
store, err := h.resources.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
limit := parseMaintenanceVerificationLimit(r.URL.Query().Get("limit"), 25, 200)
reports, err := store.ListLoopReportsForResource(unified.LoopReportTypeMaintenanceVerification, resourceID, limit)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
out := make([]maintenanceVerificationReportAPI, 0, len(reports))
for _, report := range reports {
out = append(out, toMaintenanceVerificationAPI(report))
}
writeJSON(w, http.StatusOK, map[string]any{
"data": out,
"meta": map[string]any{
"resourceId": resourceID,
"limit": limit,
"total": len(out),
},
})
}
// HandleGet serves `GET /api/maintenance-verifications/{id}`.
func (h *MaintenanceVerificationHandlers) HandleGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.resources == nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
reportID := extractMaintenanceVerificationReportID(r.URL.Path)
if reportID == "" {
http.Error(w, "Report ID required", http.StatusBadRequest)
return
}
orgID := GetOrgID(r.Context())
store, err := h.resources.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
report, found, err := store.GetLoopReport(reportID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
if !found || report.Type != unified.LoopReportTypeMaintenanceVerification {
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
"No maintenance verification report exists with this id.")
return
}
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(report))
}
// HandleReview serves
// `POST /api/maintenance-verifications/{id}/review`. Records the
// operator's "mark reviewed" verdict on a report. The report's
// status / evidence are immutable — only the review fields update.
func (h *MaintenanceVerificationHandlers) HandleReview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.resources == nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
reportID := extractMaintenanceVerificationReviewReportID(r.URL.Path)
if reportID == "" {
http.Error(w, "Report ID required", http.StatusBadRequest)
return
}
var payload maintenanceVerificationReviewRequest
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
}
orgID := GetOrgID(r.Context())
store, err := h.resources.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
report, found, err := store.GetLoopReport(reportID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
if !found || report.Type != unified.LoopReportTypeMaintenanceVerification {
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
"No maintenance verification report exists with this id.")
return
}
if err := store.UpdateLoopReportUserOutcome(
reportID,
unified.LoopReportUserOutcomeReviewed,
getUserID(r),
strings.TrimSpace(payload.Note),
time.Now().UTC(),
); err != nil {
if errors.Is(err, unified.ErrLoopReportNotFound) {
writeJSONError(w, http.StatusNotFound, "maintenance_verification_not_found",
"No maintenance verification report exists with this id.")
return
}
if errors.Is(err, unified.ErrLoopReportInvalid) {
writeJSONError(w, http.StatusBadRequest, "maintenance_verification_invalid", err.Error())
return
}
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
persisted, _, err := store.GetLoopReport(reportID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(persisted))
}
// HandleRerun serves
// `POST /api/resources/{id}/maintenance-verifications/rerun`. Runs
// the deterministic evaluator again immediately and persists a new
// report (with a `-rerun-N` suffix on the id) so the review history
// is preserved.
func (h *MaintenanceVerificationHandlers) HandleRerun(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if h.sentinel == nil {
writeJSONError(w, http.StatusServiceUnavailable, "maintenance_sentinel_unavailable",
"Maintenance verification sentinel is not running on this instance.")
return
}
resourceID := extractMaintenanceVerificationRerunResourceID(r.URL.Path)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
report, err := h.sentinel.EvaluateOnce(r.Context(), resourceID)
if err != nil {
if isMaintenanceWindowMissing(err) {
writeJSONError(w, http.StatusBadRequest, "maintenance_window_missing", err.Error())
return
}
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, toMaintenanceVerificationAPI(report))
}
func toMaintenanceVerificationAPI(report unified.LoopReport) maintenanceVerificationReportAPI {
out := maintenanceVerificationReportAPI{
ID: report.ID,
ResourceID: report.Scope,
Trigger: report.Trigger,
Goal: report.Goal,
Status: string(report.Status),
StartedAt: report.StartedAt,
CompletedAt: report.CompletedAt,
WindowStartedAt: report.WindowStartedAt,
WindowEndedAt: report.WindowEndedAt,
LinkedPatrolRunID: report.LinkedPatrolRunID,
Recommendation: report.Recommendation,
UserOutcome: string(report.UserOutcome),
ReviewedAt: report.ReviewedAt,
ReviewedBy: report.ReviewedBy,
ReviewNote: report.ReviewNote,
LinkedFindingIDs: ensureStringSlice(report.LinkedFindingIDs),
LinkedAlertIDs: ensureStringSlice(report.LinkedAlertIDs),
LinkedActionIDs: ensureStringSlice(report.LinkedActionIDs),
Evidence: maintenanceVerificationEvidenceAPI{
OperatorStateSummary: report.Evidence.OperatorStateSummary,
ActiveCriticalAlerts: report.Evidence.ActiveCriticalAlerts,
ActiveWarningAlerts: report.Evidence.ActiveWarningAlerts,
ActiveCriticalFindings: report.Evidence.ActiveCriticalFindings,
ActiveWarningFindings: report.Evidence.ActiveWarningFindings,
FailedActionsSinceWindowStart: report.Evidence.FailedActionsSinceWindowStart,
PatrolRunTODO: report.Evidence.PatrolRunTODO,
},
}
if report.Evidence.MetricRecovery != nil {
out.Evidence.MetricRecovery = &maintenanceVerificationMetricRecoveryAPI{
MetricsObserved: ensureStringSlice(report.Evidence.MetricRecovery.MetricsObserved),
SamplesAfterEnd: report.Evidence.MetricRecovery.SamplesAfterEnd,
Trend: report.Evidence.MetricRecovery.Trend,
Note: report.Evidence.MetricRecovery.Note,
}
}
return out
}
func ensureStringSlice(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func parseMaintenanceVerificationLimit(raw string, def, max int) int {
raw = strings.TrimSpace(raw)
if raw == "" {
return def
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return def
}
if v > max {
return max
}
return v
}
// extractMaintenanceVerificationsResourceID parses the canonical
// resource id out of `/api/resources/<id>/maintenance-verifications`.
func extractMaintenanceVerificationsResourceID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/resources/")
trimmed = strings.TrimSuffix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/maintenance-verifications")
trimmed = strings.TrimSuffix(trimmed, "/")
return unified.CanonicalResourceID(trimmed)
}
// extractMaintenanceVerificationRerunResourceID parses the canonical
// resource id out of
// `/api/resources/<id>/maintenance-verifications/rerun`.
func extractMaintenanceVerificationRerunResourceID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/resources/")
trimmed = strings.TrimSuffix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/maintenance-verifications/rerun")
trimmed = strings.TrimSuffix(trimmed, "/")
return unified.CanonicalResourceID(trimmed)
}
// extractMaintenanceVerificationReportID parses the report id out of
// `/api/maintenance-verifications/<id>`.
func extractMaintenanceVerificationReportID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/maintenance-verifications/")
trimmed = strings.TrimSuffix(trimmed, "/")
return strings.TrimSpace(trimmed)
}
// extractMaintenanceVerificationReviewReportID parses the report id
// out of `/api/maintenance-verifications/<id>/review`.
func extractMaintenanceVerificationReviewReportID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/maintenance-verifications/")
trimmed = strings.TrimSuffix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/review")
trimmed = strings.TrimSuffix(trimmed, "/")
return strings.TrimSpace(trimmed)
}
func isMaintenanceWindowMissing(err error) bool {
if err == nil {
return false
}
return strings.Contains(err.Error(), "no operator state for") ||
strings.Contains(err.Error(), "no maintenance window to verify")
}
// Compile-time interface satisfaction check so the router never wires
// a misshapen sentinel pointer.
var _ context.Context = context.Background()