Pulse/internal/api/resources_operator_state.go
rcourtman ee8a24e14a backend and governance: MCP contract, agent capabilities, API, and release-control
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
2026-06-23 17:26:15 +01:00

152 lines
6.3 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// resourceOperatorStateAPI is the wire shape for the operator-state API
// surface. It mirrors `unified.ResourceOperatorState` but with explicit
// JSON field names and string criticality so the contract is explicit at
// the API boundary regardless of internal type renames. The API layer
// adapts to and from `unified.ResourceOperatorState` so the storage
// type's evolution stays decoupled from the wire format.
type resourceOperatorStateAPI struct {
CanonicalID string `json:"canonicalId"`
IntentionallyOffline bool `json:"intentionallyOffline"`
NeverAutoRemediate bool `json:"neverAutoRemediate"`
MaintenanceStartAt *time.Time `json:"maintenanceStartAt,omitempty"`
MaintenanceEndAt *time.Time `json:"maintenanceEndAt,omitempty"`
MaintenanceReason string `json:"maintenanceReason,omitempty"`
Criticality string `json:"criticality,omitempty"`
Note string `json:"note,omitempty"`
SetAt time.Time `json:"setAt"`
SetBy string `json:"setBy,omitempty"`
}
func toResourceOperatorStateAPI(state unified.ResourceOperatorState) resourceOperatorStateAPI {
return resourceOperatorStateAPI{
CanonicalID: state.CanonicalID,
IntentionallyOffline: state.IntentionallyOffline,
NeverAutoRemediate: state.NeverAutoRemediate,
MaintenanceStartAt: state.MaintenanceStartAt,
MaintenanceEndAt: state.MaintenanceEndAt,
MaintenanceReason: state.MaintenanceReason,
Criticality: string(state.Criticality),
Note: state.Note,
SetAt: state.SetAt,
SetBy: state.SetBy,
}
}
// HandleResourceOperatorState dispatches GET / PUT / DELETE on
// /api/resources/{id}/operator-state. Method-keyed scope enforcement is
// done by the route registration; this handler only re-checks the method
// and routes to the underlying store calls.
//
// Wire-format contract:
// - GET returns 404 with a stable JSON error body when no entry exists,
// and 200 with the persisted state otherwise. Operators who have not
// set any state see 404, distinct from a "default state was explicitly
// written" 200 with all zero fields.
// - PUT replaces the entire record (no per-field merge). The request
// body is the full state shape; the canonical_id is taken from the
// URL path and overrides any value in the body. SetAt / SetBy are
// populated by the server from the authenticated identity and
// request time, ignoring any client-supplied values to keep the
// audit attribution honest.
// - DELETE clears the entry, idempotent. Returns 204 whether or not
// an entry was present.
func (h *ResourceHandlers) HandleResourceOperatorState(w http.ResponseWriter, r *http.Request) {
resourceID := extractOperatorStateResourceID(r.URL.Path)
if resourceID == "" {
http.Error(w, "Resource ID required", http.StatusBadRequest)
return
}
orgID := GetOrgID(r.Context())
store, err := h.getStore(orgID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
state, found, err := store.GetResourceOperatorState(resourceID)
if err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
if !found {
writeJSONError(w, http.StatusNotFound, agentcapabilities.AgentErrCodeOperatorStateNotSet,
"No operator-set state recorded for this resource.")
return
}
writeJSON(w, http.StatusOK, toResourceOperatorStateAPI(state))
case http.MethodPut:
var payload resourceOperatorStateAPI
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// canonical_id from URL wins over body to prevent scope confusion
// (operator wrote vm:101 in the URL but vm:102 in the body).
state := unified.ResourceOperatorState{
CanonicalID: resourceID,
IntentionallyOffline: payload.IntentionallyOffline,
NeverAutoRemediate: payload.NeverAutoRemediate,
MaintenanceStartAt: payload.MaintenanceStartAt,
MaintenanceEndAt: payload.MaintenanceEndAt,
MaintenanceReason: payload.MaintenanceReason,
Criticality: unified.ResourceCriticality(payload.Criticality),
Note: payload.Note,
// Server-populated attribution: ignore any client values so
// the audit trail can't be spoofed.
SetAt: time.Now().UTC(),
SetBy: getUserID(r),
}
persisted, err := unified.SetResourceOperatorStateWithMaintenanceLifecycle(store, state)
if err != nil {
if errors.Is(err, unified.ErrResourceOperatorStateInvalid) {
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeOperatorStateInvalid, err.Error())
return
}
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, toResourceOperatorStateAPI(persisted))
case http.MethodDelete:
observedAt := time.Now().UTC()
actor := getUserID(r)
if err := unified.ClearResourceOperatorStateWithMaintenanceLifecycle(store, resourceID, observedAt, actor); err != nil {
http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
// extractOperatorStateResourceID pulls the canonical resource ID out of a
// `/api/resources/<id>/operator-state` URL path. Tolerates a trailing
// slash on the URL (defense-in-depth — Go 1.22 ServeMux normally rejects
// it on pattern-match, but proxies might rewrite the path) and returns
// "" if the ID resolves to empty after canonical normalization.
func extractOperatorStateResourceID(path string) string {
trimmed := strings.TrimPrefix(path, "/api/resources/")
trimmed = strings.TrimSuffix(trimmed, "/")
trimmed = strings.TrimSuffix(trimmed, "/operator-state")
trimmed = strings.TrimSuffix(trimmed, "/")
return unified.CanonicalResourceID(trimmed)
}