mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
parent
8e760bb023
commit
2a33c3a09a
16 changed files with 241 additions and 37 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, ResourceSourceStatus>;
|
||||
relationships?: ResourceRelationship[];
|
||||
recentChanges?: ResourceChange[];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue