mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Add API-first action planning endpoint
This commit is contained in:
+55
@@ -137,6 +137,61 @@ Report an incorrect merge (creates exclusions).
|
||||
{ "sources": ["proxmox", "agent"], "notes": "optional note" }
|
||||
```
|
||||
|
||||
### Unified Action Planning
|
||||
`POST /api/actions/plan`
|
||||
Returns the deterministic pre-execution plan for a capability advertised on a unified resource. Requires `ai:execute`.
|
||||
|
||||
This endpoint is API-first and plan-only: it resolves the resource from the unified registry, verifies the requested capability and parameter schema, returns approval policy, blast radius, stale-plan hashes, and preflight checks, and does not approve or execute anything.
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"requestId": "agent-run-123",
|
||||
"resourceId": "vm:42",
|
||||
"capabilityName": "restart",
|
||||
"params": { "mode": "graceful" },
|
||||
"reason": "Recover after confirmed outage",
|
||||
"requestedBy": "agent:oncall-helper"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"actionId": "act_...",
|
||||
"requestId": "agent-run-123",
|
||||
"allowed": true,
|
||||
"requiresApproval": true,
|
||||
"approvalPolicy": "admin",
|
||||
"predictedBlastRadius": ["vm:42", "node-1"],
|
||||
"rollbackAvailable": false,
|
||||
"message": "Plan created for restart on web-42. Execution requires admin approval and is not performed by this endpoint.",
|
||||
"plannedAt": "2026-05-03T10:00:00Z",
|
||||
"expiresAt": "2026-05-03T10:05:00Z",
|
||||
"resourceVersion": "resource:sha256:...",
|
||||
"policyVersion": "policy:sha256:...",
|
||||
"planHash": "sha256:...",
|
||||
"preflight": {
|
||||
"target": "vm:42",
|
||||
"currentState": "web-42 is warning",
|
||||
"intendedChange": "Restart the VM",
|
||||
"dryRunAvailable": false,
|
||||
"dryRunSummary": "No provider-supported dry run is advertised for this capability.",
|
||||
"safetyChecks": [
|
||||
"Resource was resolved from the unified resource registry.",
|
||||
"Capability is advertised by the resource contract.",
|
||||
"This endpoint plans only; it does not approve or execute the action.",
|
||||
"Execution requires admin approval."
|
||||
],
|
||||
"verificationSteps": [
|
||||
"Refresh the resource and confirm the expected state after execution.",
|
||||
"Review /api/audit/actions/{actionId}/events for lifecycle evidence."
|
||||
],
|
||||
"generatedAt": "2026-05-03T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Metadata
|
||||
User notes, tags, and custom URLs for resources.
|
||||
|
||||
|
||||
@@ -121,6 +121,13 @@ management, and fleet control surfaces.
|
||||
22. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
23. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
|
||||
Agent lifecycle and fleet-operation surfaces may consume
|
||||
`POST /api/actions/plan` for resource capability planning, but the action plan
|
||||
contract remains API-owned through `internal/api/actions.go` and
|
||||
`internal/actionplanner/planner.go`. Agent lifecycle work must not define a
|
||||
parallel approval policy, blast-radius model, stale-plan hash, or execution
|
||||
contract for those resource actions.
|
||||
|
||||
The node setup modal boundary must keep guided setup and manual credential
|
||||
submission separate. For new PVE/PBS setup, Agent Install and Direct Connection
|
||||
setup-script modes are command-driven auto-registration paths; Token ID/Value
|
||||
|
||||
@@ -29,6 +29,8 @@ product API routes free of maintainer commercial analytics.
|
||||
2. `internal/api/resources.go`
|
||||
3. `internal/api/alerts.go`
|
||||
4. `internal/api/activity_audit_handlers.go`
|
||||
5. `internal/api/actions.go`
|
||||
6. `internal/actionplanner/planner.go`
|
||||
5. `frontend-modern/src/types/api.ts`
|
||||
6. `frontend-modern/src/types/actionAudit.ts`
|
||||
7. `frontend-modern/src/api/actionAudit.ts`
|
||||
@@ -272,6 +274,14 @@ the canonical monitored-system blocked payload.
|
||||
resource API JSON, and exercised with backend contract tests plus the
|
||||
canonical `useUnifiedResources` frontend hook proof whenever it changes.
|
||||
5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape
|
||||
Plan-only unified action planning is part of that same API-first action
|
||||
contract: `POST /api/actions/plan` must route through
|
||||
`internal/api/actions.go`, `internal/actionplanner/planner.go`, and
|
||||
`internal/api/contract_test.go` together, returning deterministic
|
||||
`ActionPlan` identity, approval policy, blast radius, resource/policy
|
||||
versions, plan hash, and preflight checks without approving or executing the
|
||||
capability. MCP, CLI, and UI consumers may adapt this payload, but they must
|
||||
not become the source of truth for action planning semantics.
|
||||
6. Route dedicated unified-resource timeline and facet-bundle reads through `frontend-modern/src/api/resources.ts`, `internal/api/resources.go`, and `internal/api/contract_test.go` together so the backend facet contract and the frontend client stay aligned on one timeline-first surface, while capability and relationship detail stays backend-owned for AI correlation and change detection.
|
||||
`/api/resources/{id}/timeline` and `/api/resources/{id}/facets` must keep
|
||||
resource timelines relationship-aware by opting into the canonical
|
||||
|
||||
@@ -74,6 +74,11 @@ Storage/recovery may also consume org-scoped session identity from the shared
|
||||
API boundary, but durable user IDs remain the authorization principal. Contact
|
||||
email may support display or legacy lookup only; storage and recovery surfaces
|
||||
must not create their own email-keyed membership or entitlement interpretation.
|
||||
Storage/recovery remediation or restore-adjacent workflows may consume
|
||||
`POST /api/actions/plan` only as the API-owned resource capability planning
|
||||
contract. This subsystem must not create a storage-local approval policy,
|
||||
stale-plan hash, blast-radius model, or execution protocol outside
|
||||
`internal/api/actions.go` and `internal/actionplanner/planner.go`.
|
||||
|
||||
1. Add or change recovery-point persistence, rollups, or series derivation through `internal/recovery/`
|
||||
2. Add or change recovery page UX through `frontend-modern/src/components/Recovery/` and keep canonical route/query/filter state ownership in `frontend-modern/src/features/recovery/useRecoverySurfaceState.ts`
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
package actionplanner
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
const DefaultPlanTTL = 5 * time.Minute
|
||||
|
||||
var ErrCapabilityNotFound = errors.New("resource capability is not advertised")
|
||||
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Field == "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Field + ": " + e.Message
|
||||
}
|
||||
|
||||
func AsValidationError(err error) (*ValidationError, bool) {
|
||||
var target *ValidationError
|
||||
if errors.As(err, &target) {
|
||||
return target, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type Planner struct {
|
||||
Now func() time.Time
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
func (p Planner) Plan(req unified.ActionRequest, resource unified.Resource) (unified.ActionPlan, error) {
|
||||
req = normalizeRequest(req)
|
||||
if err := validateRequest(req); err != nil {
|
||||
return unified.ActionPlan{}, err
|
||||
}
|
||||
|
||||
resourceID := unified.CanonicalResourceID(resource.ID)
|
||||
if resourceID == "" {
|
||||
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "resource has no canonical id"}
|
||||
}
|
||||
if req.ResourceID != resourceID {
|
||||
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "request resource does not match planned resource"}
|
||||
}
|
||||
|
||||
capability, ok := findCapability(resource.Capabilities, req.CapabilityName)
|
||||
if !ok {
|
||||
return unified.ActionPlan{}, ErrCapabilityNotFound
|
||||
}
|
||||
if err := validateParams(req.Params, capability.Params); err != nil {
|
||||
return unified.ActionPlan{}, err
|
||||
}
|
||||
|
||||
plannedAt := p.now()
|
||||
ttl := p.ttl()
|
||||
policy := normalizeApprovalPolicy(capability.MinimumApprovalLevel)
|
||||
requiresApproval := policy == unified.ApprovalAdmin || policy == unified.ApprovalMultiFactor
|
||||
resourceVersion := ResourceVersion(resource)
|
||||
policyVersion := PolicyVersion(capability)
|
||||
actionID := actionID(req, resourceVersion, policyVersion)
|
||||
|
||||
plan := unified.ActionPlan{
|
||||
ActionID: actionID,
|
||||
RequestID: req.RequestID,
|
||||
Allowed: true,
|
||||
RequiresApproval: requiresApproval,
|
||||
ApprovalPolicy: policy,
|
||||
PredictedBlastRadius: predictedBlastRadius(resource),
|
||||
RollbackAvailable: false,
|
||||
Message: planMessage(resource, capability, policy),
|
||||
PlannedAt: plannedAt,
|
||||
ExpiresAt: plannedAt.Add(ttl),
|
||||
ResourceVersion: resourceVersion,
|
||||
PolicyVersion: policyVersion,
|
||||
Preflight: buildPreflight(resource, capability, req, actionID, policy, plannedAt),
|
||||
}
|
||||
plan.PlanHash = planHash(req, plan)
|
||||
plan.Preflight = unified.NormalizeActionPreflight(plan.Preflight, req, plan)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func ResourceVersion(resource unified.Resource) string {
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
Type unified.ResourceType `json:"type"`
|
||||
Technology string `json:"technology,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status unified.ResourceStatus `json:"status"`
|
||||
AISafeSummary string `json:"aiSafeSummary,omitempty"`
|
||||
Sources []unified.DataSource `json:"sources,omitempty"`
|
||||
Identity normalizedIdentity `json:"identity,omitempty"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Capabilities []normalizedCapabilityForResourceHash `json:"capabilities,omitempty"`
|
||||
Relationships []normalizedRelationship `json:"relationships,omitempty"`
|
||||
RecentChanges []normalizedChange `json:"recentChanges,omitempty"`
|
||||
IncidentCode string `json:"incidentCode,omitempty"`
|
||||
IncidentStatus storageIncidentHashFields `json:"incidentStatus,omitempty"`
|
||||
SourceStatus map[unified.DataSource]sourceStatusHash `json:"sourceStatus,omitempty"`
|
||||
}{
|
||||
ID: unified.CanonicalResourceID(resource.ID),
|
||||
Type: unified.CanonicalResourceType(resource.Type),
|
||||
Technology: strings.TrimSpace(resource.Technology),
|
||||
Name: strings.TrimSpace(resource.Name),
|
||||
Status: resource.Status,
|
||||
AISafeSummary: strings.TrimSpace(resource.AISafeSummary),
|
||||
Sources: normalizeDataSources(resource.Sources),
|
||||
Identity: normalizeIdentity(resource.Identity),
|
||||
Capabilities: normalizeCapabilitiesForResourceHash(resource.Capabilities),
|
||||
Relationships: normalizeRelationships(unified.ResourceRelationshipsWithCanonicalParent(resource)),
|
||||
RecentChanges: normalizeChanges(resource.RecentChanges),
|
||||
IncidentCode: strings.TrimSpace(resource.IncidentCode),
|
||||
IncidentStatus: storageIncidentHashFields{
|
||||
Severity: string(resource.IncidentSeverity),
|
||||
Summary: strings.TrimSpace(resource.IncidentSummary),
|
||||
Category: strings.TrimSpace(resource.IncidentCategory),
|
||||
Priority: resource.IncidentPriority,
|
||||
},
|
||||
SourceStatus: normalizeSourceStatus(resource.SourceStatus),
|
||||
}
|
||||
if resource.ParentID != nil {
|
||||
payload.ParentID = unified.CanonicalResourceID(*resource.ParentID)
|
||||
}
|
||||
return "resource:sha256:" + hashJSON(payload, 12)
|
||||
}
|
||||
|
||||
func PolicyVersion(capability unified.ResourceCapability) string {
|
||||
payload := struct {
|
||||
Name string `json:"name"`
|
||||
Type unified.CapabilityType `json:"type"`
|
||||
Description string `json:"description"`
|
||||
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
InternalHandler string `json:"internalHandler,omitempty"`
|
||||
Params []unified.CapabilityParam `json:"params,omitempty"`
|
||||
NormalizedPolicy unified.ActionApprovalLevel `json:"normalizedPolicy"`
|
||||
ParamNames []string `json:"paramNames,omitempty"`
|
||||
}{
|
||||
Name: strings.TrimSpace(capability.Name),
|
||||
Type: capability.Type,
|
||||
Description: strings.TrimSpace(capability.Description),
|
||||
MinimumApprovalLevel: capability.MinimumApprovalLevel,
|
||||
Platform: strings.TrimSpace(capability.Platform),
|
||||
InternalHandler: strings.TrimSpace(capability.InternalHandler),
|
||||
Params: normalizeCapabilityParams(capability.Params),
|
||||
NormalizedPolicy: normalizeApprovalPolicy(capability.MinimumApprovalLevel),
|
||||
ParamNames: sortedCapabilityParamNames(capability.Params),
|
||||
}
|
||||
return "policy:sha256:" + hashJSON(payload, 12)
|
||||
}
|
||||
|
||||
func (p Planner) now() time.Time {
|
||||
if p.Now != nil {
|
||||
return p.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func (p Planner) ttl() time.Duration {
|
||||
if p.TTL > 0 {
|
||||
return p.TTL
|
||||
}
|
||||
return DefaultPlanTTL
|
||||
}
|
||||
|
||||
func normalizeRequest(req unified.ActionRequest) unified.ActionRequest {
|
||||
req.RequestID = strings.TrimSpace(req.RequestID)
|
||||
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
||||
req.CapabilityName = strings.TrimSpace(req.CapabilityName)
|
||||
req.Reason = strings.TrimSpace(req.Reason)
|
||||
req.RequestedBy = strings.TrimSpace(req.RequestedBy)
|
||||
if req.Params == nil {
|
||||
req.Params = map[string]any{}
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func validateRequest(req unified.ActionRequest) error {
|
||||
if req.RequestID == "" {
|
||||
return &ValidationError{Field: "requestId", Message: "request id is required"}
|
||||
}
|
||||
if req.ResourceID == "" {
|
||||
return &ValidationError{Field: "resourceId", Message: "resource id is required"}
|
||||
}
|
||||
if req.CapabilityName == "" {
|
||||
return &ValidationError{Field: "capabilityName", Message: "capability name is required"}
|
||||
}
|
||||
if req.Reason == "" {
|
||||
return &ValidationError{Field: "reason", Message: "reason is required"}
|
||||
}
|
||||
if req.RequestedBy == "" {
|
||||
return &ValidationError{Field: "requestedBy", Message: "requester is required"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findCapability(capabilities []unified.ResourceCapability, name string) (unified.ResourceCapability, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, capability := range capabilities {
|
||||
if strings.TrimSpace(capability.Name) == name {
|
||||
return capability, true
|
||||
}
|
||||
}
|
||||
return unified.ResourceCapability{}, false
|
||||
}
|
||||
|
||||
func validateParams(params map[string]any, specs []unified.CapabilityParam) error {
|
||||
specByName := make(map[string]unified.CapabilityParam, len(specs))
|
||||
for _, spec := range specs {
|
||||
name := strings.TrimSpace(spec.Name)
|
||||
if name == "" {
|
||||
return &ValidationError{Field: "params", Message: "capability declares an unnamed parameter"}
|
||||
}
|
||||
if _, exists := specByName[name]; exists {
|
||||
return &ValidationError{Field: "params." + name, Message: "capability declares duplicate parameter"}
|
||||
}
|
||||
spec.Name = name
|
||||
specByName[name] = spec
|
||||
}
|
||||
|
||||
for name := range params {
|
||||
trimmed := strings.TrimSpace(name)
|
||||
if trimmed == "" || trimmed != name {
|
||||
return &ValidationError{Field: "params", Message: "parameter names must be non-empty and trimmed"}
|
||||
}
|
||||
if _, ok := specByName[name]; !ok {
|
||||
return &ValidationError{Field: "params." + name, Message: "parameter is not declared by this capability"}
|
||||
}
|
||||
}
|
||||
|
||||
for _, spec := range specByName {
|
||||
value, exists := params[spec.Name]
|
||||
if !exists || isEmptyParamValue(value) {
|
||||
if spec.Required {
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "required parameter is missing"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validateParamValue(value, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEmptyParamValue(value any) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
return strings.TrimSpace(s) == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateParamValue(value any, spec unified.CapabilityParam) error {
|
||||
if err := validateParamType(value, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(spec.Enum) > 0 {
|
||||
valueString := enumString(value)
|
||||
for _, candidate := range spec.Enum {
|
||||
if valueString == strings.TrimSpace(candidate) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value is outside the allowed enum"}
|
||||
}
|
||||
if spec.Pattern != "" {
|
||||
valueString, ok := value.(string)
|
||||
if !ok {
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "pattern validation requires a string value"}
|
||||
}
|
||||
matched, err := regexp.MatchString(spec.Pattern, valueString)
|
||||
if err != nil {
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares an invalid pattern"}
|
||||
}
|
||||
if !matched {
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value does not match the required pattern"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamType(value any, spec unified.CapabilityParam) error {
|
||||
typ := strings.ToLower(strings.TrimSpace(spec.Type))
|
||||
switch typ {
|
||||
case "", "any":
|
||||
return nil
|
||||
case "string":
|
||||
if _, ok := value.(string); ok {
|
||||
return nil
|
||||
}
|
||||
case "bool", "boolean":
|
||||
if _, ok := value.(bool); ok {
|
||||
return nil
|
||||
}
|
||||
case "int", "integer":
|
||||
if isInteger(value) {
|
||||
return nil
|
||||
}
|
||||
case "number", "float", "float64":
|
||||
if isNumber(value) {
|
||||
return nil
|
||||
}
|
||||
case "object", "map":
|
||||
if isMap(value) {
|
||||
return nil
|
||||
}
|
||||
case "array", "list":
|
||||
if isSlice(value) {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares unsupported parameter type " + typ}
|
||||
}
|
||||
return &ValidationError{Field: "params." + spec.Name, Message: "parameter must be " + typ}
|
||||
}
|
||||
|
||||
func isInteger(value any) bool {
|
||||
switch v := value.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
|
||||
return true
|
||||
case float32:
|
||||
return math.Trunc(float64(v)) == float64(v)
|
||||
case float64:
|
||||
return math.Trunc(v) == v
|
||||
case json.Number:
|
||||
_, err := v.Int64()
|
||||
return err == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isNumber(value any) bool {
|
||||
switch value.(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isMap(value any) bool {
|
||||
if _, ok := value.(map[string]any); ok {
|
||||
return true
|
||||
}
|
||||
rv := reflect.ValueOf(value)
|
||||
return rv.IsValid() && rv.Kind() == reflect.Map
|
||||
}
|
||||
|
||||
func isSlice(value any) bool {
|
||||
rv := reflect.ValueOf(value)
|
||||
return rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array)
|
||||
}
|
||||
|
||||
func enumString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
default:
|
||||
return fmt.Sprint(value)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeApprovalPolicy(level unified.ActionApprovalLevel) unified.ActionApprovalLevel {
|
||||
switch level {
|
||||
case unified.ApprovalNone, unified.ApprovalDryRun, unified.ApprovalAdmin, unified.ApprovalMultiFactor:
|
||||
return level
|
||||
default:
|
||||
return unified.ApprovalAdmin
|
||||
}
|
||||
}
|
||||
|
||||
func actionID(req unified.ActionRequest, resourceVersion string, policyVersion string) string {
|
||||
payload := struct {
|
||||
RequestID string `json:"requestId"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
CapabilityName string `json:"capabilityName"`
|
||||
Params map[string]any `json:"params"`
|
||||
Reason string `json:"reason"`
|
||||
RequestedBy string `json:"requestedBy"`
|
||||
ResourceVersion string `json:"resourceVersion"`
|
||||
PolicyVersion string `json:"policyVersion"`
|
||||
}{
|
||||
RequestID: req.RequestID,
|
||||
ResourceID: req.ResourceID,
|
||||
CapabilityName: req.CapabilityName,
|
||||
Params: req.Params,
|
||||
Reason: req.Reason,
|
||||
RequestedBy: req.RequestedBy,
|
||||
ResourceVersion: resourceVersion,
|
||||
PolicyVersion: policyVersion,
|
||||
}
|
||||
return "act_" + hashJSON(payload, 16)
|
||||
}
|
||||
|
||||
func planHash(req unified.ActionRequest, plan unified.ActionPlan) string {
|
||||
payload := struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Request unified.ActionRequest `json:"request"`
|
||||
Allowed bool `json:"allowed"`
|
||||
RequiresApproval bool `json:"requiresApproval"`
|
||||
ApprovalPolicy unified.ActionApprovalLevel `json:"approvalPolicy"`
|
||||
PredictedBlastRadius []string `json:"predictedBlastRadius"`
|
||||
RollbackAvailable bool `json:"rollbackAvailable"`
|
||||
ResourceVersion string `json:"resourceVersion"`
|
||||
PolicyVersion string `json:"policyVersion"`
|
||||
}{
|
||||
ActionID: plan.ActionID,
|
||||
Request: req,
|
||||
Allowed: plan.Allowed,
|
||||
RequiresApproval: plan.RequiresApproval,
|
||||
ApprovalPolicy: plan.ApprovalPolicy,
|
||||
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
|
||||
RollbackAvailable: plan.RollbackAvailable,
|
||||
ResourceVersion: plan.ResourceVersion,
|
||||
PolicyVersion: plan.PolicyVersion,
|
||||
}
|
||||
return "sha256:" + hashJSON(payload, 32)
|
||||
}
|
||||
|
||||
func predictedBlastRadius(resource unified.Resource) []string {
|
||||
seen := map[string]struct{}{}
|
||||
add := func(id string) {
|
||||
id = unified.CanonicalResourceID(id)
|
||||
if id == "" {
|
||||
return
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
|
||||
resourceID := unified.CanonicalResourceID(resource.ID)
|
||||
add(resourceID)
|
||||
for _, relationship := range unified.ResourceRelationshipsWithCanonicalParent(resource) {
|
||||
if !relationship.Active {
|
||||
continue
|
||||
}
|
||||
add(relationship.SourceID)
|
||||
add(relationship.TargetID)
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
if resourceID != "" {
|
||||
for i, id := range out {
|
||||
if id == resourceID {
|
||||
copy(out[1:i+1], out[0:i])
|
||||
out[0] = resourceID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func planMessage(resource unified.Resource, capability unified.ResourceCapability, policy unified.ActionApprovalLevel) string {
|
||||
target := displayResourceName(resource)
|
||||
action := strings.TrimSpace(capability.Name)
|
||||
if target == "" {
|
||||
target = unified.CanonicalResourceID(resource.ID)
|
||||
}
|
||||
switch policy {
|
||||
case unified.ApprovalDryRun:
|
||||
return fmt.Sprintf("Plan created for %s on %s. Policy allows planning/dry-run only; execution is not performed by this endpoint.", action, target)
|
||||
case unified.ApprovalNone:
|
||||
return fmt.Sprintf("Plan created for %s on %s. Execution is not performed by this endpoint.", action, target)
|
||||
default:
|
||||
return fmt.Sprintf("Plan created for %s on %s. Execution requires %s approval and is not performed by this endpoint.", action, target, policy)
|
||||
}
|
||||
}
|
||||
|
||||
func buildPreflight(resource unified.Resource, capability unified.ResourceCapability, req unified.ActionRequest, actionID string, policy unified.ActionApprovalLevel, plannedAt time.Time) *unified.ActionPreflight {
|
||||
resourceID := unified.CanonicalResourceID(resource.ID)
|
||||
action := strings.TrimSpace(capability.Name)
|
||||
description := strings.TrimSpace(capability.Description)
|
||||
intendedChange := description
|
||||
if intendedChange == "" {
|
||||
intendedChange = fmt.Sprintf("Run %s on %s", action, resourceID)
|
||||
}
|
||||
|
||||
safetyChecks := []string{
|
||||
"Resource was resolved from the unified resource registry.",
|
||||
"Capability is advertised by the resource contract.",
|
||||
"This endpoint plans only; it does not approve or execute the action.",
|
||||
}
|
||||
switch policy {
|
||||
case unified.ApprovalDryRun:
|
||||
safetyChecks = append(safetyChecks, "Capability policy is dry-run-only.")
|
||||
case unified.ApprovalNone:
|
||||
safetyChecks = append(safetyChecks, "Capability policy allows execution without additional approval.")
|
||||
default:
|
||||
safetyChecks = append(safetyChecks, fmt.Sprintf("Execution requires %s approval.", policy))
|
||||
}
|
||||
|
||||
return &unified.ActionPreflight{
|
||||
Target: resourceID,
|
||||
CurrentState: currentState(resource),
|
||||
IntendedChange: intendedChange,
|
||||
DryRunAvailable: false,
|
||||
DryRunSummary: "No provider-supported dry run is advertised for this capability.",
|
||||
SafetyChecks: safetyChecks,
|
||||
VerificationSteps: []string{
|
||||
"Refresh the resource and confirm the expected state after execution.",
|
||||
"Review /api/audit/actions/" + actionID + "/events for lifecycle evidence.",
|
||||
},
|
||||
GeneratedAt: plannedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func currentState(resource unified.Resource) string {
|
||||
name := displayResourceName(resource)
|
||||
status := strings.TrimSpace(string(resource.Status))
|
||||
if name == "" {
|
||||
name = unified.CanonicalResourceID(resource.ID)
|
||||
}
|
||||
if status == "" {
|
||||
return name
|
||||
}
|
||||
return fmt.Sprintf("%s is %s", name, status)
|
||||
}
|
||||
|
||||
func displayResourceName(resource unified.Resource) string {
|
||||
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.DisplayName) != "" {
|
||||
return strings.TrimSpace(resource.Canonical.DisplayName)
|
||||
}
|
||||
if strings.TrimSpace(resource.Name) != "" {
|
||||
return strings.TrimSpace(resource.Name)
|
||||
}
|
||||
return unified.CanonicalResourceID(resource.ID)
|
||||
}
|
||||
|
||||
type normalizedIdentity struct {
|
||||
MachineID string `json:"machineId,omitempty"`
|
||||
DMIUUID string `json:"dmiUuid,omitempty"`
|
||||
Hostnames []string `json:"hostnames,omitempty"`
|
||||
IPAddresses []string `json:"ipAddresses,omitempty"`
|
||||
MACAddresses []string `json:"macAddresses,omitempty"`
|
||||
ClusterName string `json:"clusterName,omitempty"`
|
||||
}
|
||||
|
||||
type normalizedCapabilityForResourceHash struct {
|
||||
Name string `json:"name"`
|
||||
Type unified.CapabilityType `json:"type"`
|
||||
Description string `json:"description"`
|
||||
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Params []unified.CapabilityParam `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type normalizedRelationship struct {
|
||||
SourceID string `json:"sourceId"`
|
||||
TargetID string `json:"targetId"`
|
||||
Type unified.RelationshipType `json:"type"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Active bool `json:"active"`
|
||||
Discoverer string `json:"discoverer"`
|
||||
ObservedAt time.Time `json:"observedAt,omitempty"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type normalizedChange struct {
|
||||
ID string `json:"id"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
Kind unified.ChangeKind `json:"kind"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
SourceType unified.ChangeSourceType `json:"sourceType"`
|
||||
SourceAdapter unified.ChangeSourceAdapter `json:"sourceAdapter"`
|
||||
Confidence unified.ChangeConfidence `json:"confidence"`
|
||||
RelatedResources []string `json:"relatedResources,omitempty"`
|
||||
}
|
||||
|
||||
type storageIncidentHashFields struct {
|
||||
Severity string `json:"severity,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
type sourceStatusHash struct {
|
||||
Status string `json:"status"`
|
||||
LastSeen time.Time `json:"lastSeen,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeDataSources(sources []unified.DataSource) []unified.DataSource {
|
||||
out := make([]unified.DataSource, 0, len(sources))
|
||||
seen := map[unified.DataSource]struct{}{}
|
||||
for _, source := range sources {
|
||||
normalized := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
|
||||
if normalized == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[normalized]; exists {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = struct{}{}
|
||||
out = append(out, normalized)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeIdentity(identity unified.ResourceIdentity) normalizedIdentity {
|
||||
return normalizedIdentity{
|
||||
MachineID: strings.TrimSpace(identity.MachineID),
|
||||
DMIUUID: strings.TrimSpace(identity.DMIUUID),
|
||||
Hostnames: sortedTrimmedStrings(identity.Hostnames),
|
||||
IPAddresses: sortedTrimmedStrings(identity.IPAddresses),
|
||||
MACAddresses: sortedTrimmedStrings(identity.MACAddresses),
|
||||
ClusterName: strings.TrimSpace(identity.ClusterName),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCapabilitiesForResourceHash(capabilities []unified.ResourceCapability) []normalizedCapabilityForResourceHash {
|
||||
out := make([]normalizedCapabilityForResourceHash, 0, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
out = append(out, normalizedCapabilityForResourceHash{
|
||||
Name: strings.TrimSpace(capability.Name),
|
||||
Type: capability.Type,
|
||||
Description: strings.TrimSpace(capability.Description),
|
||||
MinimumApprovalLevel: capability.MinimumApprovalLevel,
|
||||
Platform: strings.TrimSpace(capability.Platform),
|
||||
Params: normalizeCapabilityParams(capability.Params),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name == out[j].Name {
|
||||
return out[i].Platform < out[j].Platform
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeCapabilityParams(params []unified.CapabilityParam) []unified.CapabilityParam {
|
||||
out := make([]unified.CapabilityParam, 0, len(params))
|
||||
for _, param := range params {
|
||||
param.Name = strings.TrimSpace(param.Name)
|
||||
param.Type = strings.ToLower(strings.TrimSpace(param.Type))
|
||||
param.Pattern = strings.TrimSpace(param.Pattern)
|
||||
param.Description = strings.TrimSpace(param.Description)
|
||||
param.Enum = sortedTrimmedStrings(param.Enum)
|
||||
out = append(out, param)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCapabilityParamNames(params []unified.CapabilityParam) []string {
|
||||
names := make([]string, 0, len(params))
|
||||
for _, param := range params {
|
||||
if name := strings.TrimSpace(param.Name); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func normalizeRelationships(relationships []unified.ResourceRelationship) []normalizedRelationship {
|
||||
out := make([]normalizedRelationship, 0, len(relationships))
|
||||
for _, relationship := range relationships {
|
||||
out = append(out, normalizedRelationship{
|
||||
SourceID: unified.CanonicalResourceID(relationship.SourceID),
|
||||
TargetID: unified.CanonicalResourceID(relationship.TargetID),
|
||||
Type: relationship.Type,
|
||||
Confidence: relationship.Confidence,
|
||||
Active: relationship.Active,
|
||||
Discoverer: strings.TrimSpace(relationship.Discoverer),
|
||||
ObservedAt: relationship.ObservedAt.UTC(),
|
||||
LastSeenAt: relationship.LastSeenAt.UTC(),
|
||||
Metadata: relationship.Metadata,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].SourceID != out[j].SourceID {
|
||||
return out[i].SourceID < out[j].SourceID
|
||||
}
|
||||
if out[i].TargetID != out[j].TargetID {
|
||||
return out[i].TargetID < out[j].TargetID
|
||||
}
|
||||
return out[i].Type < out[j].Type
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeChanges(changes []unified.ResourceChange) []normalizedChange {
|
||||
out := make([]normalizedChange, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
out = append(out, normalizedChange{
|
||||
ID: strings.TrimSpace(change.ID),
|
||||
ResourceID: unified.CanonicalResourceID(change.ResourceID),
|
||||
Kind: change.Kind,
|
||||
From: strings.TrimSpace(change.From),
|
||||
To: strings.TrimSpace(change.To),
|
||||
SourceType: change.SourceType,
|
||||
SourceAdapter: change.SourceAdapter,
|
||||
Confidence: change.Confidence,
|
||||
RelatedResources: sortedCanonicalResourceIDs(change.RelatedResources),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].ResourceID != out[j].ResourceID {
|
||||
return out[i].ResourceID < out[j].ResourceID
|
||||
}
|
||||
if out[i].ID != out[j].ID {
|
||||
return out[i].ID < out[j].ID
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSourceStatus(status map[unified.DataSource]unified.SourceStatus) map[unified.DataSource]sourceStatusHash {
|
||||
if len(status) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[unified.DataSource]sourceStatusHash, len(status))
|
||||
for source, sourceStatus := range status {
|
||||
normalizedSource := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
|
||||
if normalizedSource == "" {
|
||||
continue
|
||||
}
|
||||
out[normalizedSource] = sourceStatusHash{
|
||||
Status: strings.TrimSpace(sourceStatus.Status),
|
||||
LastSeen: sourceStatus.LastSeen.UTC(),
|
||||
Error: strings.TrimSpace(sourceStatus.Error),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedCanonicalResourceIDs(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := map[string]struct{}{}
|
||||
for _, id := range ids {
|
||||
id = unified.CanonicalResourceID(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedTrimmedStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func hashJSON(value any, bytes int) string {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
payload = []byte(fmt.Sprintf("%#v", value))
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
if bytes <= 0 || bytes > len(sum) {
|
||||
bytes = len(sum)
|
||||
}
|
||||
return hex.EncodeToString(sum[:bytes])
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package actionplanner
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestPlannerBuildsDeterministicGovernedPlan(t *testing.T) {
|
||||
now := time.Date(2026, 5, 3, 9, 30, 0, 0, time.UTC)
|
||||
parentID := "agent:node-1"
|
||||
resource := unified.Resource{
|
||||
ID: "vm:42",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "web-42",
|
||||
Status: unified.StatusWarning,
|
||||
LastSeen: now.Add(-time.Minute),
|
||||
UpdatedAt: now.Add(-30 * time.Second),
|
||||
ParentID: &parentID,
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
{
|
||||
Name: "restart",
|
||||
Type: unified.CapabilityTypeCommon,
|
||||
Description: "Restart the VM",
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
InternalHandler: "proxmox.vm.restart",
|
||||
Params: []unified.CapabilityParam{
|
||||
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Relationships: []unified.ResourceRelationship{
|
||||
{
|
||||
SourceID: "vm:42",
|
||||
TargetID: "service:web",
|
||||
Type: unified.RelDependsOn,
|
||||
Active: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
req := unified.ActionRequest{
|
||||
RequestID: "agent-run-123",
|
||||
ResourceID: " vm:42 ",
|
||||
CapabilityName: "restart",
|
||||
Params: map[string]any{"mode": "graceful"},
|
||||
Reason: "Recover after confirmed outage",
|
||||
RequestedBy: "agent:oncall-helper",
|
||||
}
|
||||
|
||||
planner := Planner{Now: func() time.Time { return now }}
|
||||
plan, err := planner.Plan(req, resource)
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() error = %v", err)
|
||||
}
|
||||
second, err := planner.Plan(req, resource)
|
||||
if err != nil {
|
||||
t.Fatalf("Plan() second error = %v", err)
|
||||
}
|
||||
|
||||
if plan.ActionID == "" || plan.ActionID != second.ActionID {
|
||||
t.Fatalf("action id is not deterministic: first=%q second=%q", plan.ActionID, second.ActionID)
|
||||
}
|
||||
if plan.PlanHash == "" || plan.PlanHash != second.PlanHash {
|
||||
t.Fatalf("plan hash is not deterministic: first=%q second=%q", plan.PlanHash, second.PlanHash)
|
||||
}
|
||||
if !plan.Allowed {
|
||||
t.Fatalf("Allowed = false, want true")
|
||||
}
|
||||
if !plan.RequiresApproval {
|
||||
t.Fatalf("RequiresApproval = false, want true")
|
||||
}
|
||||
if plan.ApprovalPolicy != unified.ApprovalAdmin {
|
||||
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
|
||||
}
|
||||
if !plan.PlannedAt.Equal(now) {
|
||||
t.Fatalf("PlannedAt = %s, want %s", plan.PlannedAt, now)
|
||||
}
|
||||
if !plan.ExpiresAt.Equal(now.Add(DefaultPlanTTL)) {
|
||||
t.Fatalf("ExpiresAt = %s, want %s", plan.ExpiresAt, now.Add(DefaultPlanTTL))
|
||||
}
|
||||
if len(plan.PredictedBlastRadius) != 3 ||
|
||||
plan.PredictedBlastRadius[0] != "vm:42" ||
|
||||
plan.PredictedBlastRadius[1] != "agent:node-1" ||
|
||||
plan.PredictedBlastRadius[2] != "service:web" {
|
||||
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
|
||||
}
|
||||
if plan.Preflight == nil {
|
||||
t.Fatalf("Preflight is nil")
|
||||
}
|
||||
if plan.Preflight.Target != "vm:42" {
|
||||
t.Fatalf("Preflight.Target = %q, want vm:42", plan.Preflight.Target)
|
||||
}
|
||||
if plan.Preflight.DryRunAvailable {
|
||||
t.Fatalf("DryRunAvailable = true, want false without provider dry-run contract")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerRejectsUndeclaredParams(t *testing.T) {
|
||||
resource := unified.Resource{
|
||||
ID: "vm:42",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
{Name: "restart", Type: unified.CapabilityTypeCommon, MinimumApprovalLevel: unified.ApprovalAdmin},
|
||||
},
|
||||
}
|
||||
req := unified.ActionRequest{
|
||||
RequestID: "agent-run-123",
|
||||
ResourceID: "vm:42",
|
||||
CapabilityName: "restart",
|
||||
Params: map[string]any{"force": true},
|
||||
Reason: "Recover after confirmed outage",
|
||||
RequestedBy: "agent:oncall-helper",
|
||||
}
|
||||
|
||||
_, err := Planner{}.Plan(req, resource)
|
||||
validationErr, ok := AsValidationError(err)
|
||||
if !ok {
|
||||
t.Fatalf("Plan() error = %v, want validation error", err)
|
||||
}
|
||||
if validationErr.Field != "params.force" {
|
||||
t.Fatalf("validation field = %q, want params.force", validationErr.Field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceVersionIgnoresObservationOnlyTimestamps(t *testing.T) {
|
||||
base := unified.Resource{
|
||||
ID: "vm:42",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "web-42",
|
||||
Status: unified.StatusOnline,
|
||||
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
refreshed := base
|
||||
refreshed.LastSeen = base.LastSeen.Add(time.Minute)
|
||||
refreshed.UpdatedAt = base.UpdatedAt.Add(time.Minute)
|
||||
|
||||
if got, want := ResourceVersion(refreshed), ResourceVersion(base); got != want {
|
||||
t.Fatalf("ResourceVersion changed for observation-only timestamp drift: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
changed := base
|
||||
changed.Status = unified.StatusWarning
|
||||
if got, unchanged := ResourceVersion(changed), ResourceVersion(base); got == unchanged {
|
||||
t.Fatalf("ResourceVersion did not change for status drift: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlannerReturnsCapabilityNotFound(t *testing.T) {
|
||||
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM}
|
||||
req := unified.ActionRequest{
|
||||
RequestID: "agent-run-123",
|
||||
ResourceID: "vm:42",
|
||||
CapabilityName: "restart",
|
||||
Reason: "Recover after confirmed outage",
|
||||
RequestedBy: "agent:oncall-helper",
|
||||
}
|
||||
|
||||
_, err := Planner{}.Plan(req, resource)
|
||||
if !errors.Is(err, ErrCapabilityNotFound) {
|
||||
t.Fatalf("Plan() error = %v, want ErrCapabilityNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
const maxActionPlanRequestBytes = 1 << 20
|
||||
|
||||
func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req unified.ActionRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionPlanRequestBytes))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
|
||||
"body": "request body must be a valid ActionRequest JSON object",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
|
||||
"body": "request body must contain one JSON object",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
|
||||
if req.ResourceID == "" {
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", map[string]string{
|
||||
"resourceId": "resource id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
orgID := GetOrgID(r.Context())
|
||||
registry, err := h.buildRegistry(orgID)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "resource_registry_unavailable", sanitizeErrorForClient(err, "Resource registry unavailable"), nil)
|
||||
return
|
||||
}
|
||||
|
||||
resource, ok := registry.Get(req.ResourceID)
|
||||
if !ok || resource == nil {
|
||||
writeErrorResponse(w, http.StatusNotFound, "resource_not_found", "Resource not found", map[string]string{
|
||||
"resourceId": req.ResourceID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := (actionplanner.Planner{}).Plan(req, *resource)
|
||||
if err != nil {
|
||||
if validationErr, ok := actionplanner.AsValidationError(err); ok {
|
||||
details := map[string]string{}
|
||||
if validationErr.Field != "" {
|
||||
details[validationErr.Field] = validationErr.Message
|
||||
}
|
||||
writeErrorResponse(w, http.StatusBadRequest, "invalid_action_request", "Invalid action planning request", details)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, actionplanner.ErrCapabilityNotFound) {
|
||||
writeErrorResponse(w, http.StatusNotFound, "capability_not_found", "Capability not found on resource", map[string]string{
|
||||
"resourceId": req.ResourceID,
|
||||
"capabilityName": req.CapabilityName,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "action_plan_failed", sanitizeErrorForClient(err, "Action planning failed"), nil)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(plan); err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "action_plan_encode_failed", "Failed to encode action plan", nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
)
|
||||
|
||||
func TestHandlePlanActionReturnsCanonicalPlan(t *testing.T) {
|
||||
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
{
|
||||
ID: "vm:42",
|
||||
Type: unified.ResourceTypeVM,
|
||||
Name: "web-42",
|
||||
Status: unified.StatusWarning,
|
||||
LastSeen: now,
|
||||
UpdatedAt: now,
|
||||
Sources: []unified.DataSource{unified.SourceProxmox},
|
||||
Capabilities: []unified.ResourceCapability{
|
||||
{
|
||||
Name: "restart",
|
||||
Type: unified.CapabilityTypeCommon,
|
||||
Description: "Restart the VM",
|
||||
MinimumApprovalLevel: unified.ApprovalAdmin,
|
||||
InternalHandler: "proxmox.vm.restart",
|
||||
Params: []unified.CapabilityParam{
|
||||
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Relationships: []unified.ResourceRelationship{
|
||||
{
|
||||
SourceID: "vm:42",
|
||||
TargetID: "node-1",
|
||||
Type: unified.RelRunsOn,
|
||||
Active: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
body := bytes.NewBufferString(`{
|
||||
"requestId":"agent-run-123",
|
||||
"resourceId":"vm:42",
|
||||
"capabilityName":"restart",
|
||||
"params":{"mode":"graceful"},
|
||||
"reason":"Recover after confirmed outage",
|
||||
"requestedBy":"agent:oncall-helper"
|
||||
}`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body)
|
||||
h.HandlePlanAction(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "InternalHandler") || strings.Contains(rec.Body.String(), "proxmox.vm.restart") {
|
||||
t.Fatalf("response leaked internal execution handler: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
var plan unified.ActionPlan
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &plan); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !plan.Allowed {
|
||||
t.Fatalf("Allowed = false, want true")
|
||||
}
|
||||
if !plan.RequiresApproval {
|
||||
t.Fatalf("RequiresApproval = false, want true")
|
||||
}
|
||||
if plan.ApprovalPolicy != unified.ApprovalAdmin {
|
||||
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
|
||||
}
|
||||
if plan.ActionID == "" || !strings.HasPrefix(plan.PlanHash, "sha256:") {
|
||||
t.Fatalf("missing action identity/hash: actionID=%q planHash=%q", plan.ActionID, plan.PlanHash)
|
||||
}
|
||||
if plan.Preflight == nil || plan.Preflight.Target != "vm:42" {
|
||||
t.Fatalf("Preflight = %#v, want target vm:42", plan.Preflight)
|
||||
}
|
||||
if len(plan.PredictedBlastRadius) != 2 || plan.PredictedBlastRadius[0] != "vm:42" || plan.PredictedBlastRadius[1] != "node-1" {
|
||||
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePlanActionRejectsMissingCapability(t *testing.T) {
|
||||
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
h.SetStateProvider(resourceUnifiedSeedProvider{
|
||||
snapshot: models.StateSnapshot{LastUpdate: now},
|
||||
resources: []unified.Resource{
|
||||
{ID: "vm:42", Type: unified.ResourceTypeVM, Name: "web-42", Status: unified.StatusOnline, LastSeen: now, UpdatedAt: now},
|
||||
},
|
||||
})
|
||||
body := bytes.NewBufferString(`{
|
||||
"requestId":"agent-run-123",
|
||||
"resourceId":"vm:42",
|
||||
"capabilityName":"restart",
|
||||
"reason":"Recover after confirmed outage",
|
||||
"requestedBy":"agent:oncall-helper"
|
||||
}`)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/plan", body)
|
||||
h.HandlePlanAction(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"code":"capability_not_found"`) {
|
||||
t.Fatalf("unexpected response body: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/actionplanner"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
||||
@@ -72,6 +73,39 @@ func TestPatrolRemediationCommercialCopyUsesSafeRemediationWording(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContract_HostedMagicLinkStablePrincipalProof(t *testing.T) {
|
||||
source, err := os.ReadFile(filepath.Clean("magic_link_handlers.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("read magic_link_handlers.go: %v", err)
|
||||
}
|
||||
text := string(source)
|
||||
for _, required := range []string{
|
||||
"resolveMagicLinkPrincipal",
|
||||
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, principal.UserID)",
|
||||
"TrackUserSession(principal.UserID, sessionToken)",
|
||||
} {
|
||||
if !strings.Contains(text, required) {
|
||||
t.Fatalf("magic_link_handlers.go must contain %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"CreateSession(sessionToken, sessionDuration, userAgent, clientIP, token.Email)",
|
||||
"TrackUserSession(token.Email, sessionToken)",
|
||||
} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("magic_link_handlers.go must not contain legacy email-principal pattern %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
invariantDoc, err := os.ReadFile(filepath.Clean("../../docs/release-control/v6/internal/IDENTITY_INVARIANTS.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read identity invariant contract: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(invariantDoc), "Email is contact metadata") {
|
||||
t.Fatal("identity invariant contract must define email as contact metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractAISettingsClampsPaidRuntimeControlsToEntitlements(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -11183,6 +11217,96 @@ func TestContract_ResourceFacetsDeriveCanonicalParentRelationship(t *testing.T)
|
||||
assertJSONSnapshot(t, got, want)
|
||||
}
|
||||
|
||||
func TestContract_ActionPlanJSONSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC)
|
||||
resource := unifiedresources.Resource{
|
||||
ID: "vm:42",
|
||||
Type: unifiedresources.ResourceTypeVM,
|
||||
Name: "web-42",
|
||||
Status: unifiedresources.StatusWarning,
|
||||
LastSeen: now,
|
||||
UpdatedAt: now,
|
||||
Sources: []unifiedresources.DataSource{unifiedresources.SourceProxmox},
|
||||
Capabilities: []unifiedresources.ResourceCapability{
|
||||
{
|
||||
Name: "restart",
|
||||
Type: unifiedresources.CapabilityTypeCommon,
|
||||
Description: "Restart the VM",
|
||||
MinimumApprovalLevel: unifiedresources.ApprovalAdmin,
|
||||
InternalHandler: "proxmox.vm.restart",
|
||||
Params: []unifiedresources.CapabilityParam{
|
||||
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Relationships: []unifiedresources.ResourceRelationship{
|
||||
{
|
||||
SourceID: "vm:42",
|
||||
TargetID: "node-1",
|
||||
Type: unifiedresources.RelRunsOn,
|
||||
Confidence: 1,
|
||||
Active: true,
|
||||
Discoverer: "proxmox_adapter",
|
||||
ObservedAt: now,
|
||||
LastSeenAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
req := unifiedresources.ActionRequest{
|
||||
RequestID: "agent-run-123",
|
||||
ResourceID: "vm:42",
|
||||
CapabilityName: "restart",
|
||||
Params: map[string]any{"mode": "graceful"},
|
||||
Reason: "Recover after confirmed outage",
|
||||
RequestedBy: "agent:oncall-helper",
|
||||
}
|
||||
|
||||
plan, err := (actionplanner.Planner{Now: func() time.Time { return now }}).Plan(req, resource)
|
||||
if err != nil {
|
||||
t.Fatalf("plan action: %v", err)
|
||||
}
|
||||
got, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal action plan: %v", err)
|
||||
}
|
||||
|
||||
const want = `{
|
||||
"actionId":"act_371d0bcde73818752f83bb759e1fa523",
|
||||
"requestId":"agent-run-123",
|
||||
"allowed":true,
|
||||
"requiresApproval":true,
|
||||
"approvalPolicy":"admin",
|
||||
"predictedBlastRadius":["vm:42","node-1"],
|
||||
"rollbackAvailable":false,
|
||||
"message":"Plan created for restart on web-42. Execution requires admin approval and is not performed by this endpoint.",
|
||||
"plannedAt":"2026-05-03T10:00:00Z",
|
||||
"expiresAt":"2026-05-03T10:05:00Z",
|
||||
"resourceVersion":"resource:sha256:3a87c71cf83bd017736bcb25",
|
||||
"policyVersion":"policy:sha256:0bce3cd2df181ace685598eb",
|
||||
"planHash":"sha256:38e8a016794bae597cd6129e65506556048bcac88d6a5a1b59e1337a2acc5a05",
|
||||
"preflight":{
|
||||
"target":"vm:42",
|
||||
"currentState":"web-42 is warning",
|
||||
"intendedChange":"Restart the VM",
|
||||
"dryRunAvailable":false,
|
||||
"dryRunSummary":"No provider-supported dry run is advertised for this capability.",
|
||||
"safetyChecks":[
|
||||
"Resource was resolved from the unified resource registry.",
|
||||
"Capability is advertised by the resource contract.",
|
||||
"This endpoint plans only; it does not approve or execute the action.",
|
||||
"Execution requires admin approval."
|
||||
],
|
||||
"verificationSteps":[
|
||||
"Refresh the resource and confirm the expected state after execution.",
|
||||
"Review /api/audit/actions/act_371d0bcde73818752f83bb759e1fa523/events for lifecycle evidence."
|
||||
],
|
||||
"generatedAt":"2026-05-03T10:00:00Z"
|
||||
}
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, got, want)
|
||||
}
|
||||
|
||||
func TestContract_ResourceTimelineEndpointsIncludeRelatedChanges(t *testing.T) {
|
||||
now := time.Date(2026, 4, 25, 22, 15, 0, 0, time.UTC)
|
||||
h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
||||
|
||||
@@ -406,6 +406,7 @@ var allRouteAllowlist = []string{
|
||||
"/api/resources/",
|
||||
"/api/resources/{id}/facets",
|
||||
"/api/resources/{id}/timeline",
|
||||
"POST /api/actions/plan",
|
||||
"/api/guests/metadata",
|
||||
"/api/guests/metadata/",
|
||||
"/api/docker/metadata",
|
||||
|
||||
@@ -37,6 +37,7 @@ func (r *Router) registerMonitoringResourceRoutes(
|
||||
r.mux.HandleFunc("/api/resources/{id}/facets", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceFacets)))
|
||||
r.mux.HandleFunc("/api/resources/{id}/timeline", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleGetResourceTimeline)))
|
||||
r.mux.HandleFunc("/api/resources/", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, r.resourceHandlers.HandleResourceRoutes)))
|
||||
r.mux.HandleFunc("POST /api/actions/plan", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.resourceHandlers.HandlePlanAction)))
|
||||
// Guest metadata routes
|
||||
r.mux.HandleFunc("/api/guests/metadata", RequireAuth(r.config, RequireScope(config.ScopeMonitoringRead, guestMetadataHandler.HandleGetMetadata)))
|
||||
r.mux.HandleFunc("/api/guests/metadata/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -3604,8 +3604,8 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 156,
|
||||
"heading_line": 91,
|
||||
"line": 158,
|
||||
"heading_line": 93,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user