diff --git a/docs/API.md b/docs/API.md index 75d9b2963..356e17d04 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a8b221596..2de1dcadf 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 314f4ab0b..44ff8767f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 12067e7e4..28779a0af 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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` diff --git a/internal/actionplanner/planner.go b/internal/actionplanner/planner.go new file mode 100644 index 000000000..bf6f1beaa --- /dev/null +++ b/internal/actionplanner/planner.go @@ -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]) +} diff --git a/internal/actionplanner/planner_test.go b/internal/actionplanner/planner_test.go new file mode 100644 index 000000000..f488dd29a --- /dev/null +++ b/internal/actionplanner/planner_test.go @@ -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) + } +} diff --git a/internal/api/actions.go b/internal/api/actions.go new file mode 100644 index 000000000..4903c54ef --- /dev/null +++ b/internal/api/actions.go @@ -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) + } +} diff --git a/internal/api/actions_test.go b/internal/api/actions_test.go new file mode 100644 index 000000000..cd46e0457 --- /dev/null +++ b/internal/api/actions_test.go @@ -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()) + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index d9439ac44..d3f7cc5f5 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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()}) diff --git a/internal/api/route_inventory_test.go b/internal/api/route_inventory_test.go index 79f234405..97e1ab8a4 100644 --- a/internal/api/route_inventory_test.go +++ b/internal/api/route_inventory_test.go @@ -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", diff --git a/internal/api/router_routes_monitoring.go b/internal/api/router_routes_monitoring.go index 6b697f067..a31720c3b 100644 --- a/internal/api/router_routes_monitoring.go +++ b/internal/api/router_routes_monitoring.go @@ -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) { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 06e32e167..950c1d1f9 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -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, } ], )