From 2a33c3a09a0ea22dae4d3bf3818229a435df023b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 23:58:24 +0100 Subject: [PATCH] Add Docker action readiness reasons Refs #1034 --- .../v6/internal/subsystems/agent-lifecycle.md | 4 +- .../v6/internal/subsystems/api-contracts.md | 4 ++ .../subsystems/frontend-primitives.md | 4 ++ .../internal/subsystems/storage-recovery.md | 4 +- .../internal/subsystems/unified-resources.md | 10 ++- .../dockerContainerLifecycleActions.test.ts | 19 ++++++ .../docker/dockerContainerLifecycleActions.ts | 29 +++++++++ frontend-modern/src/types/resource.ts | 8 +++ internal/api/actions.go | 7 +- internal/api/contract_test.go | 37 +++++++++++ .../api/docker_container_action_executor.go | 64 ++++++++++++++++--- .../docker_container_action_executor_test.go | 26 ++++++-- internal/api/resources.go | 8 ++- internal/unifiedresources/capabilities.go | 9 +++ .../unifiedresources/code_standards_test.go | 10 +++ internal/unifiedresources/types.go | 35 +++++----- 16 files changed, 241 insertions(+), 37 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index dd9782b2e..082071a92 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -239,7 +239,9 @@ Disconnected command-agent state is also API-owned readiness: lifecycle surfaces may reflect missing backend-advertised capabilities, but must not reconnect, substitute, or directly address an agent to make a stale container action executable. Backend resource payloads and plan-action readiness are the -only supported lifecycle signal for that state. +only supported lifecycle signal for that state. When a resource payload carries +typed `actionReadiness`, lifecycle surfaces may display the reason but must not +treat it as reconnect authority or an alternate command grant. Assistant session rename through `PATCH /api/ai/sessions/{id}` follows that same browser-safe history boundary. Lifecycle surfaces, MCP adapters, and agents may display the updated title as human navigation metadata, but they diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0a8e25aa7..43e3805c2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1013,6 +1013,10 @@ the canonical monitored-system blocked payload. uses that hook for command-agent connectivity and runtime posture; browser controls may consume advertised capabilities, but must not replace this check with direct shell, SSH, provider, or agent-command calls. + Resource payloads may expose the same executor-owned unavailable state as + `actionReadiness[]` entries with stable `name`, `available`, `reasonCode`, + and `reason` fields so browser and agent clients can explain disabled + actions without treating unavailable capabilities as executable. Action approval decisions are API-owned as a separate non-execution contract: `POST /api/actions/{id}/decision` may only record an `approved` or `rejected` decision against a persisted `pending_approval` diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4cc338d88..4ab32c3f0 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -255,6 +255,10 @@ metadata-only API responses, and the unified-resource owner supplies the Namespace, ConfigMap, Secret, and ServiceAccount-specific columns. Kubernetes Node inventory must also be reachable through a dedicated native tab, not only the overview stack, while retaining the shared `PlatformSectionTabs` shell. +Feature-owned Docker / Podman action controls may render backend +`actionReadiness` disabled reasons, but the shared primitive layer owns only the +button/table affordance shell; it must not invent action availability, command +agent state, or alternate execution routes. Kubernetes Service inventory must likewise stay on the shared tab, toolbar, table, table-alignment, and inline-detail primitives while the unified-resource owner supplies Service type, virtual IP, published port, and selector columns. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 94d27c88c..b6b7a17ab 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -230,7 +230,9 @@ restore support, or a recovery-local execution path. Disconnected command-agent readiness for those lifecycle actions remains an API/runtime fail-closed condition; storage/recovery consumers may observe that an action is unavailable, but must not reinterpret it as recovery degradation -or attempt a recovery-local container command path. +or attempt a recovery-local container command path. Typed resource +`actionReadiness` reasons are operator explanation only in this subsystem, not +backup freshness, restore support, or recovery action authority. Storage and recovery surfaces may consume Discovery context from the shared API boundary when it helps explain protected workloads or storage-adjacent services, including mock-mode config/data/log path examples. That context is diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 6310616ae..5ebf61f68 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -446,6 +446,12 @@ container inventory table. same readiness boundary and fails closed with `action_execution_unavailable` before audit creation if the live command path disappears after the resource response was read. + When filtering an otherwise known lifecycle capability, the same resource + response must preserve a typed `actionReadiness` entry with the action name, + `available=false`, a stable reason code such as + `command_agent_disconnected`, and operator-safe copy. Frontend consumers may + use that field to explain disabled controls, while `capabilities` remains + the executable action set. TrueNAS app inventory enters the model as native `TrueNASData.App` metadata on canonical `app-container` resources. The facet is sourced from the TrueNAS API app inventory (`app.query` plus active workload/stat @@ -1248,8 +1254,8 @@ Docker / Podman container lifecycle controls in `dockerContainerLifecycleActions.ts` are unified-resource capability consumers: they may enable start/stop/restart only from backend-advertised resource capabilities and must use `sourceStatus`, `docker.agentId`, `docker.runtime`, -and `docker.security` only for disabled-state explanation, never as a -feature-local execution bypass. The same controls may render in +`docker.security`, and backend-owned `actionReadiness` only for disabled-state +explanation, never as a feature-local execution bypass. The same controls may render in `DockerContainersTable.tsx` and the canonical `ResourceDetailDrawer.tsx` header only for Docker-source app containers backed by Docker or Podman runtime metadata; other app-container sources such as TrueNAS must not inherit Docker diff --git a/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts b/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts index c2bd116b8..77584f3b7 100644 --- a/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts +++ b/frontend-modern/src/features/docker/__tests__/dockerContainerLifecycleActions.test.ts @@ -152,4 +152,23 @@ describe('dockerContainerLifecycleActions', () => { 'Pulse does not currently advertise a fresh restart command capability for this container.', ); }); + + it('prefers backend-owned action readiness reasons over generic missing capability copy', () => { + expect( + getDockerContainerLifecycleDisabledReason( + resource({ + capabilities: [], + actionReadiness: [ + { + name: 'restart', + available: false, + reasonCode: 'command_agent_disconnected', + reason: 'Docker / Podman command agent is not connected.', + }, + ], + }), + 'restart', + ), + ).toBe('Docker / Podman command agent is not connected.'); + }); }); diff --git a/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts b/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts index cb789b1ba..96241215f 100644 --- a/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts +++ b/frontend-modern/src/features/docker/dockerContainerLifecycleActions.ts @@ -89,6 +89,32 @@ const stateDisabledReason = ( return undefined; }; +const actionReadinessDisabledReason = ( + resource: Resource, + action: DockerContainerLifecycleAction, +): string | undefined => { + const readiness = resource.actionReadiness?.find( + (item) => normalizeToken(item.name) === action && item.available === false, + ); + if (!readiness) return undefined; + const reason = asTrimmedString(readiness.reason); + if (reason) return reason; + switch (normalizeToken(readiness.reasonCode)) { + case 'command_agent_disconnected': + return 'Docker / Podman command agent is not connected.'; + case 'command_agent_unavailable': + return 'Docker / Podman command execution is not available.'; + case 'stale_inventory': + return 'Docker / Podman inventory is not fresh enough to run lifecycle actions.'; + case 'host_policy_blocked': + return 'Docker / Podman host policy blocks mutating lifecycle actions.'; + case 'unsupported_handler': + return 'This container action is not routed through the supported lifecycle executor.'; + default: + return undefined; + } +}; + export const getDockerContainerLifecycleDisabledReason = ( resource: Resource, action: DockerContainerLifecycleAction, @@ -117,6 +143,9 @@ export const getDockerContainerLifecycleDisabledReason = ( const stateReason = stateDisabledReason(resource, action); if (stateReason) return stateReason; + const readinessReason = actionReadinessDisabledReason(resource, action); + if (readinessReason) return readinessReason; + if (!dockerContainerLifecycleCapability(resource, action)) { return `Pulse does not currently advertise a fresh ${action} command capability for this container.`; } diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 3b1632e37..1b0e6a2e7 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -312,6 +312,13 @@ export interface ResourceCapability { params?: ResourceCapabilityParam[]; } +export interface ResourceActionReadiness { + name: string; + available: boolean; + reasonCode?: string; + reason?: string; +} + export interface ResourceSourceStatus { status?: string; lastSeen?: string; @@ -1422,6 +1429,7 @@ export interface Resource { policy?: ResourcePolicy; aiSafeSummary?: string; capabilities?: ResourceCapability[]; + actionReadiness?: ResourceActionReadiness[]; sourceStatus?: Record; relationships?: ResourceRelationship[]; recentChanges?: ResourceChange[]; diff --git a/internal/api/actions.go b/internal/api/actions.go index 89a653ffe..6dd78db41 100644 --- a/internal/api/actions.go +++ b/internal/api/actions.go @@ -27,7 +27,7 @@ type ActionExecutor interface { // ActionAvailabilityChecker lets an executor contribute live readiness checks // before Pulse advertises or persists an executable action plan. type ActionAvailabilityChecker interface { - CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) error + CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness } type actionDecisionRequest struct { @@ -121,11 +121,12 @@ func (h *ResourceHandlers) HandlePlanAction(w http.ResponseWriter, r *http.Reque req = normalizeActionRequestForAudit(req) if checker, ok := h.actionExecutor.(ActionAvailabilityChecker); ok { - if err := checker.CheckActionAvailable(r.Context(), req, *resource); err != nil { + if readiness := checker.CheckActionAvailable(r.Context(), req, *resource); readiness.Name != "" && !readiness.Available { writeJSONErrorWithDetails(w, http.StatusConflict, "action_execution_unavailable", "Action execution is unavailable", map[string]string{ "resourceId": req.ResourceID, "capabilityName": req.CapabilityName, - "reason": sanitizeErrorForClient(err, "action execution is unavailable"), + "reasonCode": readiness.ReasonCode, + "reason": firstNonEmpty(readiness.Reason, "action execution is unavailable"), }) return } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 9391def7a..3e98d716c 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -15150,6 +15150,43 @@ func TestContract_PlanActionDeclaresExecutionUnavailable(t *testing.T) { } } +func TestContract_ResourceActionReadinessPayloadShape(t *testing.T) { + payload, err := json.Marshal(unifiedresources.Resource{ + ID: "app-container:docker:web", + Type: unifiedresources.ResourceTypeAppContainer, + Name: "web", + Status: unifiedresources.StatusOnline, + LastSeen: time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC), + Sources: []unifiedresources.DataSource{unifiedresources.SourceDocker}, + ActionReadiness: []unifiedresources.ResourceActionReadiness{ + { + Name: "restart", + Available: false, + ReasonCode: "command_agent_disconnected", + Reason: "Docker / Podman command agent is not connected.", + }, + }, + }) + if err != nil { + t.Fatalf("marshal resource: %v", err) + } + body := string(payload) + for _, want := range []string{ + `"actionReadiness":[`, + `"name":"restart"`, + `"available":false`, + `"reasonCode":"command_agent_disconnected"`, + `"reason":"Docker / Podman command agent is not connected."`, + } { + if !strings.Contains(body, want) { + t.Fatalf("resource action readiness payload missing %s: %s", want, body) + } + } + if strings.Contains(body, "InternalHandler") { + t.Fatalf("resource payload leaked internal action handler: %s", body) + } +} + // TestContract_AgentCapabilitiesManifestIsPublic pins the auth // contract for the discovery surface: the manifest must be in the // router's publicPaths list so it serves without a token. The diff --git a/internal/api/docker_container_action_executor.go b/internal/api/docker_container_action_executor.go index 8159fcc52..d34b06c9e 100644 --- a/internal/api/docker_container_action_executor.go +++ b/internal/api/docker_container_action_executor.go @@ -57,7 +57,7 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record return nil, fmt.Errorf("docker container resource %q is not backed by a reporting Pulse agent", record.Request.ResourceID) } if !e.agents.IsAgentConnected(agentID) { - return nil, fmt.Errorf("docker container command agent %q is not connected", agentID) + return nil, fmt.Errorf("docker container command agent is not connected") } command := fmt.Sprintf("%s %s %s", runtime, operation, shellQuote(containerRef)) @@ -91,23 +91,24 @@ func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record return execution, nil } -func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) error { +func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness { operation := strings.TrimSpace(req.CapabilityName) capability, ok := findDockerLifecycleCapability(resource.Capabilities, operation) if !ok || capability.InternalHandler != dockerContainerLifecycleHandler { - return nil + return unified.ResourceActionReadiness{} } + readiness := unified.ResourceActionReadiness{Name: operation, Available: true} if e.agents == nil { - return fmt.Errorf("docker container command agent manager is not available") + return unavailableDockerActionReadiness(operation, "command_agent_unavailable", "Docker / Podman command execution is not available.") } if _, err := e.executableDockerContainerResource(ctx, resource, operation); err != nil { - return err + return unavailableDockerActionReadiness(operation, dockerActionUnavailableReasonCode(err), dockerActionUnavailableReason(err)) } agentID := strings.TrimSpace(resource.Docker.AgentID) if !e.agents.IsAgentConnected(agentID) { - return fmt.Errorf("docker container command agent %q is not connected", agentID) + return unavailableDockerActionReadiness(operation, "command_agent_disconnected", "Docker / Podman command agent is not connected.") } - return nil + return readiness } func (e dockerContainerActionExecutor) currentDockerContainerResource(ctx context.Context, resourceID, operation string) (unified.Resource, error) { @@ -129,7 +130,7 @@ func (e dockerContainerActionExecutor) executableDockerContainerResource(_ conte if resource.Type != unified.ResourceTypeAppContainer || resource.Docker == nil { return unified.Resource{}, fmt.Errorf("resource %q is not a Docker or Podman container", resource.ID) } - if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") { + if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") || strings.EqualFold(strings.TrimSpace(status.Status), "missing") { return unified.Resource{}, fmt.Errorf("docker inventory for resource %q is %s", resource.ID, status.Status) } if resource.Docker.Security != nil && resource.Docker.Security.MutatingCommandsBlocked { @@ -147,6 +148,53 @@ func (e dockerContainerActionExecutor) executableDockerContainerResource(_ conte return resource, nil } +func unavailableDockerActionReadiness(operation, reasonCode, reason string) unified.ResourceActionReadiness { + return unified.ResourceActionReadiness{ + Name: strings.TrimSpace(operation), + Available: false, + ReasonCode: strings.TrimSpace(reasonCode), + Reason: strings.TrimSpace(reason), + } +} + +func dockerActionUnavailableReasonCode(err error) string { + if err == nil { + return "unavailable" + } + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "not a docker or podman container"): + return "unsupported_resource" + case strings.Contains(message, "inventory") && (strings.Contains(message, "stale") || strings.Contains(message, "offline") || strings.Contains(message, "missing")): + return "stale_inventory" + case strings.Contains(message, "blocked by host policy"): + return "host_policy_blocked" + case strings.Contains(message, "does not currently advertise"): + return "capability_unavailable" + case strings.Contains(message, "unsupported handler"): + return "unsupported_handler" + default: + return "unavailable" + } +} + +func dockerActionUnavailableReason(err error) string { + switch dockerActionUnavailableReasonCode(err) { + case "unsupported_resource": + return "Resource is not a Docker or Podman container." + case "stale_inventory": + return "Docker / Podman inventory is not fresh enough to run lifecycle actions." + case "host_policy_blocked": + return "Docker / Podman host policy blocks mutating lifecycle actions." + case "capability_unavailable": + return "Pulse does not currently advertise a fresh command capability for this container." + case "unsupported_handler": + return "This container action is not routed through the supported lifecycle executor." + default: + return "Docker / Podman lifecycle action is not currently available." + } +} + func findDockerLifecycleCapability(capabilities []unified.ResourceCapability, operation string) (unified.ResourceCapability, bool) { operation = strings.TrimSpace(operation) for _, capability := range capabilities { diff --git a/internal/api/docker_container_action_executor_test.go b/internal/api/docker_container_action_executor_test.go index 45842e3ed..1d822795c 100644 --- a/internal/api/docker_container_action_executor_test.go +++ b/internal/api/docker_container_action_executor_test.go @@ -48,6 +48,15 @@ func dockerActionCapabilityNames(capabilities []unified.ResourceCapability) []st return names } +func dockerActionReadinessByName(readinesses []unified.ResourceActionReadiness, name string) (unified.ResourceActionReadiness, bool) { + for _, readiness := range readinesses { + if readiness.Name == name { + return readiness, true + } + } + return unified.ResourceActionReadiness{}, false +} + func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) { now := time.Now().UTC() h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) @@ -116,15 +125,15 @@ func TestDockerContainerActionExecutorAvailabilityRequiresConnectedAgent(t *test agents := &fakeDockerActionAgentCommander{connected: map[string]bool{"agent-1": false}} executor := newDockerContainerActionExecutor(h, agents).(dockerContainerActionExecutor) - err := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{ + readiness := executor.CheckActionAvailable(context.Background(), unified.ActionRequest{ RequestID: "req-availability", ResourceID: "app-container:api", CapabilityName: "restart", Reason: "operator requested restart", RequestedBy: "operator", }, resource) - if err == nil || !strings.Contains(err.Error(), `docker container command agent "agent-1" is not connected`) { - t.Fatalf("CheckActionAvailable err = %v, want disconnected agent", err) + if readiness.Available || readiness.ReasonCode != "command_agent_disconnected" || readiness.Reason != "Docker / Podman command agent is not connected." { + t.Fatalf("CheckActionAvailable readiness = %#v, want disconnected agent", readiness) } if len(agents.calls) != 0 { t.Fatalf("agent calls = %#v, want none", agents.calls) @@ -158,7 +167,8 @@ func TestHandlePlanActionRejectsDisconnectedDockerContainerAgent(t *testing.T) { t.Fatalf("plan status = %d, want %d, body=%s", rec.Code, http.StatusConflict, rec.Body.String()) } if !strings.Contains(rec.Body.String(), `"error":"action_execution_unavailable"`) || - !strings.Contains(rec.Body.String(), `"reason":"action execution is unavailable"`) { + !strings.Contains(rec.Body.String(), `"reason":"Docker / Podman command agent is not connected."`) || + !strings.Contains(rec.Body.String(), `"reasonCode":"command_agent_disconnected"`) { t.Fatalf("unexpected response body: %s", rec.Body.String()) } store, err := h.getStore("default") @@ -203,6 +213,10 @@ func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testi if got := dockerActionCapabilityNames(list.Data[0].Capabilities); len(got) != 0 { t.Fatalf("list capabilities = %#v, want none", got) } + readiness, ok := dockerActionReadinessByName(list.Data[0].ActionReadiness, "restart") + if !ok || readiness.Available || readiness.ReasonCode != "command_agent_disconnected" { + t.Fatalf("list action readiness = %#v, ok=%v; want disconnected restart", list.Data[0].ActionReadiness, ok) + } detailRec := httptest.NewRecorder() detailReq := httptest.NewRequest(http.MethodGet, "/api/resources/app-container:api", nil) @@ -217,6 +231,10 @@ func TestResourceResponsesFilterDisconnectedDockerLifecycleCapabilities(t *testi if got := dockerActionCapabilityNames(detail.Capabilities); len(got) != 0 { t.Fatalf("detail capabilities = %#v, want none", got) } + readiness, ok = dockerActionReadinessByName(detail.ActionReadiness, "restart") + if !ok || readiness.Available || readiness.Reason != "Docker / Podman command agent is not connected." { + t.Fatalf("detail action readiness = %#v, ok=%v; want disconnected restart", detail.ActionReadiness, ok) + } } func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T) { diff --git a/internal/api/resources.go b/internal/api/resources.go index 8ac8cb628..5282903a0 100644 --- a/internal/api/resources.go +++ b/internal/api/resources.go @@ -319,6 +319,7 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource continue } filtered := make([]unified.ResourceCapability, 0, len(resources[i].Capabilities)) + readinesses := make([]unified.ResourceActionReadiness, 0, len(resources[i].Capabilities)) for _, capability := range resources[i].Capabilities { req := unified.ActionRequest{ RequestID: "resource-capability-availability", @@ -328,7 +329,9 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource Reason: "Projecting currently executable resource capabilities.", RequestedBy: "system:resource-api", } - if err := checker.CheckActionAvailable(ctx, req, resources[i]); err != nil { + readiness := checker.CheckActionAvailable(ctx, req, resources[i]) + if readiness.Name != "" && !readiness.Available { + readinesses = append(readinesses, readiness) continue } filtered = append(filtered, capability) @@ -338,6 +341,9 @@ func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resource } else { resources[i].Capabilities = filtered } + if len(readinesses) > 0 { + resources[i].ActionReadiness = readinesses + } } } diff --git a/internal/unifiedresources/capabilities.go b/internal/unifiedresources/capabilities.go index cd08ab0c7..e6c9e4a3d 100644 --- a/internal/unifiedresources/capabilities.go +++ b/internal/unifiedresources/capabilities.go @@ -40,3 +40,12 @@ type ResourceCapability struct { InternalHandler string `json:"-"` // DO NOT expose execution plumbing to public surfaces Params []CapabilityParam `json:"params,omitempty"` } + +// ResourceActionReadiness carries backend-owned current executability state +// for a capability that may be known but unavailable right now. +type ResourceActionReadiness struct { + Name string `json:"name"` + Available bool `json:"available"` + ReasonCode string `json:"reasonCode,omitempty"` + Reason string `json:"reason,omitempty"` +} diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 2096b6685..60fc10a17 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -572,9 +572,18 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) { "func RedactAuditRecord(record ActionAuditRecord) ActionAuditRecord", "func redactActionExecutionResult(result *ExecutionResult) *ExecutionResult", }, + filepath.Join(".", "capabilities.go"): { + "type ResourceCapability struct", + "type ResourceActionReadiness struct", + "ReasonCode string `json:\"reasonCode,omitempty\"`", + }, + filepath.Join(".", "types.go"): { + "ActionReadiness []ResourceActionReadiness `json:\"actionReadiness,omitempty\"`", + }, filepath.Join("..", "api", "actions.go"): { "type ActionExecutor interface", "type ActionAvailabilityChecker interface", + "CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness", "func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)", "func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error", "func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord", @@ -591,6 +600,7 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) { "func (h *ResourceHandlers) SetActionExecutor(executor ActionExecutor)", "func (h *ResourceHandlers) SetActionCompletedPublisher(", "func (h *ResourceHandlers) applyActionAvailability(ctx context.Context, resources []unified.Resource)", + "resources[i].ActionReadiness = readinesses", }, filepath.Join("..", "api", "agent_events.go"): { "func (b *AgentEventBroadcaster) PublishActionCompletedRecord(record unifiedresources.ActionAuditRecord)", diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index ed4660eec..9fd08af8d 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -47,23 +47,24 @@ type Resource struct { ChildCount int `json:"childCount,omitempty"` parentBySource map[DataSource]string - Tags []string `json:"tags,omitempty"` - CustomURL string `json:"customUrl,omitempty"` - Capabilities []ResourceCapability `json:"capabilities,omitempty"` - Relationships []ResourceRelationship `json:"relationships,omitempty"` - RecentChanges []ResourceChange `json:"recentChanges,omitempty"` - FacetCounts ResourceFacetCounts `json:"facetCounts,omitempty"` - Incidents []ResourceIncident `json:"incidents,omitempty"` - IncidentCount int `json:"incidentCount,omitempty"` - IncidentCode string `json:"incidentCode,omitempty"` - IncidentSeverity storagehealth.RiskLevel `json:"incidentSeverity,omitempty"` - IncidentSummary string `json:"incidentSummary,omitempty"` - IncidentCategory string `json:"incidentCategory,omitempty"` - IncidentLabel string `json:"incidentLabel,omitempty"` - IncidentPriority int `json:"incidentPriority,omitempty"` - IncidentImpactSummary string `json:"incidentImpactSummary,omitempty"` - IncidentUrgency string `json:"incidentUrgency,omitempty"` - IncidentAction string `json:"incidentAction,omitempty"` + Tags []string `json:"tags,omitempty"` + CustomURL string `json:"customUrl,omitempty"` + Capabilities []ResourceCapability `json:"capabilities,omitempty"` + ActionReadiness []ResourceActionReadiness `json:"actionReadiness,omitempty"` + Relationships []ResourceRelationship `json:"relationships,omitempty"` + RecentChanges []ResourceChange `json:"recentChanges,omitempty"` + FacetCounts ResourceFacetCounts `json:"facetCounts,omitempty"` + Incidents []ResourceIncident `json:"incidents,omitempty"` + IncidentCount int `json:"incidentCount,omitempty"` + IncidentCode string `json:"incidentCode,omitempty"` + IncidentSeverity storagehealth.RiskLevel `json:"incidentSeverity,omitempty"` + IncidentSummary string `json:"incidentSummary,omitempty"` + IncidentCategory string `json:"incidentCategory,omitempty"` + IncidentLabel string `json:"incidentLabel,omitempty"` + IncidentPriority int `json:"incidentPriority,omitempty"` + IncidentImpactSummary string `json:"incidentImpactSummary,omitempty"` + IncidentUrgency string `json:"incidentUrgency,omitempty"` + IncidentAction string `json:"incidentAction,omitempty"` // Source-specific payloads Proxmox *ProxmoxData `json:"proxmox,omitempty"`