From 6725d6a784a62e134b928a1e9365752431f4bd2c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 12 Jun 2026 21:17:58 +0100 Subject: [PATCH] Add governed Docker and Podman lifecycle actions Refs #1034 --- .../v6/internal/subsystems/agent-lifecycle.md | 9 + .../v6/internal/subsystems/ai-runtime.md | 5 +- .../v6/internal/subsystems/api-contracts.md | 3 +- .../v6/internal/subsystems/monitoring.md | 4 + .../subsystems/performance-and-scalability.md | 5 + .../internal/subsystems/security-privacy.md | 5 + .../internal/subsystems/storage-recovery.md | 5 + .../internal/subsystems/unified-resources.md | 8 + internal/agentexec/policy.go | 12 + internal/agentexec/policy_test.go | 4 + internal/ai/tools/control_test.go | 2 +- internal/ai/tools/tools_control.go | 4 + internal/ai/tools/tools_docker.go | 34 ++- internal/api/actions.go | 37 ++- internal/api/agent_capabilities.go | 4 +- internal/api/contract_test.go | 1 + .../api/docker_container_action_executor.go | 258 ++++++++++++++++++ .../docker_container_action_executor_test.go | 239 ++++++++++++++++ internal/api/router.go | 3 + internal/unifiedresources/adapters.go | 102 ++++++- internal/unifiedresources/adapters_test.go | 144 ++++++++++ internal/unifiedresources/clone.go | 10 + .../unifiedresources/code_standards_test.go | 31 +++ internal/unifiedresources/types.go | 1 + internal/unifiedresources/views.go | 9 + 25 files changed, 916 insertions(+), 23 deletions(-) create mode 100644 internal/api/docker_container_action_executor.go create mode 100644 internal/api/docker_container_action_executor_test.go diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 613608017..86b5d2ee9 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -230,6 +230,11 @@ the API-owned action audit records `executing` before dispatch and the terminal execution result afterward. Dry-run-only plans remain planning evidence only; lifecycle surfaces must not present them as executable, dispatch them through agent-local command paths, or bypass the API fail-closed execution gate. +Docker / Podman container start, stop, and restart affordances follow that same +boundary: lifecycle UI may consume unified-resource capabilities for enabled or +disabled presentation, but execution must stay inside the API-owned action +executor wired from `internal/api/router.go` and must not shell out, SSH, or call +Docker / Podman from lifecycle-local code. 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 @@ -1355,6 +1360,10 @@ The handlers were migrated from the platform-wide `APIError` envelope to the agent-stable `{"error", "message", "details"?}` shape so the substrate keeps a single envelope contract across read, write, and action capabilities. +Docker / Podman lifecycle execution extends that same action category only: +the manifest advertises the `execute_action` substrate, while per-resource +container capabilities, policy checks, action audit records, and terminal +verification remain API-owned facts rather than agent lifecycle state. The findings runtime now consumes operator-set per-resource state through a provider adapter wired in `internal/api/router.go` at diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 86749c75c..6ec48e166 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -2103,8 +2103,9 @@ deriving an older display status from `workflowStatusHistory`. persisted on `ExecutionResult.Verification` so the audit history shows not only what the action did but whether the read-back confirmed the intended state. Container-class verification is - deferred to pulse_docker's existing tool-level `docker inspect` - check; classes without a derivable verification command leave + deferred to pulse_docker's existing tool-level runtime inspect + check (`docker inspect` or `podman inspect`, according to the + resolved container runtime); classes without a derivable verification command leave `Verification` nil rather than fabricating a verified=true entry. The approval preflight presented to operators authors per-command-class safety and verification context on top of the default broker-level diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b423c07bf..de5c557cd 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -2493,7 +2493,8 @@ carries `missing_id`, `invalid_id`, `invalid_action_decision`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, -`action_plan_drift`, `action_executor_unavailable`. 5xx internal-failure codes +`action_plan_expired`, `action_plan_drift`, +`resource_remediation_locked`, `action_executor_unavailable`. 5xx internal-failure codes (audit-store outages, encode failures) are not declared per capability; agents branch on 5xx generically. diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 3c2d1e2f1..65f9cc3f2 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -568,6 +568,10 @@ shared Docker host model, and `internal/monitoring/docker_commands.go` must refuse Docker daemon-mutating commands when authorization plugins are configured until the upstream Moby authz-plugin advisory line has a fixed Go module release. +Unified-resource Docker / Podman lifecycle capabilities consume that preserved +posture and must fail closed when it blocks mutation; monitoring remains the +runtime truth producer and must not grow a monitor-local start/stop/restart +transport around the governed action executor. That same collector boundary also owns the maintained engine-client seam. `internal/dockeragent/docker_client.go`, `internal/dockeragent/collect.go`, and `internal/dockeragent/swarm.go` must keep Pulse's package-local diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index a38b55c0d..09527e320 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -278,6 +278,11 @@ regression protection. action audit record to the agent SSE projection, but it must not perform executor dispatch, resource rescans, model calls, or persistence fan-out on the protected request setup path. + Docker / Podman lifecycle execution uses the same bounded setup rule: + `internal/api/router.go` may wire the API action executor once at startup, + while per-action resource refresh, policy validation, agent command dispatch, + polling verification, and audit completion must occur inside the route-local + execute handler path. Retiring self-hosted trial acquisition follows that same rule: removing `/auth/trial-activate` and `POST /api/license/trial/start` from public-path and CSRF inventories must stay as constant-time route-table absence rather diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 3d93ea426..b65487d2b 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -599,6 +599,11 @@ read-only or mobile-only grant. `POST /api/actions/{id}/execute` is governed by the same `ai:execute` scope because it is the only API-owned handoff from approved intent into capability execution; missing executors must fail closed without creating execution lifecycle evidence. +Docker / Podman container lifecycle execution stays under that same privileged +handoff: the executor may use agent command execution only after scope, +approval/policy, stale-plan, operator-lock, source-freshness, and runtime +posture checks pass, and it must record redacted audit and verification facts +instead of exposing raw command text through monitoring-readable surfaces. That same token-scope boundary now also governs Pulse Mobile relay runtime credentials: `internal/api/security_tokens.go` must mint only the dedicated backend-owned `relay:mobile:access` scope for new mobile relay tokens, and the diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 2a3fda587..7be5cd4d0 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -215,6 +215,11 @@ driver runs. Dry-run-only plans remain planning evidence only; storage and recovery surfaces must not present them as executable, dispatch them through provider-local restore/remediation paths, or bypass the API fail-closed execution gate. +Docker / Podman start, stop, and restart actions are adjacent runtime actions, +not storage or recovery controls: storage/recovery consumers may render their +redacted action history as context, but must not treat container lifecycle +capabilities, `DockerData`, or agent command verification as backup freshness, +restore support, or a recovery-local execution path. 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 ff1361122..8e7087cb3 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -423,6 +423,14 @@ container inventory table. `resourceVersion`, `policyVersion`, or `planHash` drift as `action_plan_drift`, and record/publish a failed audit instead of leaving executor adapters to make stale-approval decisions. + Docker/Podman `app-container` lifecycle actions are part of that same + governed resource contract: `resourceFromDockerContainer` may advertise + `start`, `stop`, and `restart` only for fresh, supported, agent-backed + Docker/Podman reports whose daemon posture does not block mutating + commands. The API action executor must consume those capabilities through + `/api/actions/*` and the agent command server; platform rows or drawers + must not wire direct shell, SSH, provider, or runtime calls around this + capability contract. 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 diff --git a/internal/agentexec/policy.go b/internal/agentexec/policy.go index e089f965d..c25b16e33 100644 --- a/internal/agentexec/policy.go +++ b/internal/agentexec/policy.go @@ -171,6 +171,14 @@ func DefaultPolicy() *CommandPolicy { `^docker\s+images`, `^docker\s+network\s+ls`, `^docker\s+volume\s+ls`, + `^podman\s+ps`, + `^podman\s+logs\s`, + `^podman\s+inspect\s`, + `^podman\s+stats\s+--no-stream`, + `^podman\s+top\s`, + `^podman\s+images`, + `^podman\s+network\s+ls`, + `^podman\s+volume\s+ls`, // Proxmox inspection (read-only) `^pct\s+list`, @@ -201,6 +209,7 @@ func DefaultPolicy() *CommandPolicy { // Docker inspection (read-only) `^docker\s+system\s+df`, + `^podman\s+system\s+df`, // APT inspection (read-only) `^apt\s+list`, @@ -218,6 +227,9 @@ func DefaultPolicy() *CommandPolicy { `^docker\s+(restart|stop|start|kill)\s`, `^docker\s+exec\s`, `^docker\s+rm\s`, + `^podman\s+(restart|stop|start|kill)\s`, + `^podman\s+exec\s`, + `^podman\s+rm\s`, // Process control `^kill\s`, diff --git a/internal/agentexec/policy_test.go b/internal/agentexec/policy_test.go index 4e98b0c93..ef4abcf1d 100644 --- a/internal/agentexec/policy_test.go +++ b/internal/agentexec/policy_test.go @@ -31,7 +31,11 @@ func TestDefaultPolicyEvaluate(t *testing.T) { {"auto approve safe proc meminfo", "cat /proc/meminfo", PolicyAllow}, {"auto approve ipv4 ping probe", "ping -c 1 -W 1 10.0.0.1", PolicyAllow}, {"auto approve ipv6 ping probe", "ping -c 1 -W 1 2001:db8::1", PolicyAllow}, + {"auto approve podman inspect", "podman inspect api", PolicyAllow}, + {"auto approve podman system df", "podman system df", PolicyAllow}, {"require approval", "systemctl restart nginx", PolicyRequireApproval}, + {"podman restart requires approval", "podman restart api", PolicyRequireApproval}, + {"podman stop requires approval", "podman stop api", PolicyRequireApproval}, {"generic proc read requires approval", "cat /proc/1/status", PolicyRequireApproval}, {"hostname ping requires approval", "ping -c 1 -W 1 example.com", PolicyRequireApproval}, {"unknown defaults to approval", "echo hello", PolicyRequireApproval}, diff --git a/internal/ai/tools/control_test.go b/internal/ai/tools/control_test.go index 29b3abdd7..3d1355abf 100644 --- a/internal/ai/tools/control_test.go +++ b/internal/ai/tools/control_test.go @@ -126,7 +126,7 @@ func TestPulseToolExecutor_ExecuteControlDocker(t *testing.T) { }) agentSrv.On("ExecuteCommand", mock.Anything, "agent1", mock.MatchedBy(func(payload agentexec.ExecuteCommandPayload) bool { - return payload.Command == "docker restart nginx" + return payload.Command == "docker restart 'nginx'" })).Return(&agentexec.CommandResultPayload{ Stdout: "OK", ExitCode: 0, diff --git a/internal/ai/tools/tools_control.go b/internal/ai/tools/tools_control.go index 093a1cb8f..0afd36eed 100644 --- a/internal/ai/tools/tools_control.go +++ b/internal/ai/tools/tools_control.go @@ -1087,7 +1087,11 @@ func dockerHostModelFromView(host *unifiedresources.DockerHostView) models.Docke AgentID: strings.TrimSpace(host.AgentID()), Hostname: hostname, DisplayName: displayName, + Runtime: strings.TrimSpace(host.Runtime()), Status: string(host.Status()), + LastSeen: host.LastSeen(), + Command: host.Command(), + Security: host.Security(), } } diff --git a/internal/ai/tools/tools_docker.go b/internal/ai/tools/tools_docker.go index a70be9fd4..fcfa58d69 100644 --- a/internal/ai/tools/tools_docker.go +++ b/internal/ai/tools/tools_docker.go @@ -9,6 +9,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) @@ -128,7 +129,8 @@ func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[s return NewTextResult(fmt.Sprintf("Could not find Docker container '%s': %v", containerName, err)), nil } - command := fmt.Sprintf("docker %s %s", operation, container.Name) + runtimeCommand := dockerRuntimeCommand(dockerHost) + command := fmt.Sprintf("%s %s %s", runtimeCommand, operation, shellEscape(container.Name)) approvalTargetID := fmt.Sprintf("%s:%s", dockerHost.ID, container.Name) if dockerHost.ID == "" { approvalTargetID = fmt.Sprintf("%s:%s", dockerHost.Hostname, container.Name) @@ -216,7 +218,7 @@ func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[s output = redacted + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) } - verify := e.verifyDockerContainerState(ctx, routing, container.ID, operation) + verify := e.verifyDockerContainerState(ctx, routing, runtimeCommand, container.ID, operation) verify["ok"] = result.ExitCode == 0 response := map[string]interface{}{ @@ -235,9 +237,21 @@ func (e *PulseToolExecutor) executeDockerControl(ctx context.Context, args map[s return NewJSONResultWithIsError(response, result.ExitCode != 0), nil } +func dockerRuntimeCommand(dockerHost *models.DockerHost) string { + if dockerHost != nil { + switch strings.ToLower(strings.TrimSpace(dockerHost.Runtime)) { + case "podman": + return "podman" + case "docker": + return "docker" + } + } + return "docker" +} + // ========== Docker Updates Handler Implementations ========== -func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, routing CommandRoutingResult, containerID, operation string) map[string]interface{} { +func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, routing CommandRoutingResult, runtimeCommand, containerID, operation string) map[string]interface{} { expectRunning := false switch operation { case "start", "restart": @@ -246,7 +260,11 @@ func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, rout expectRunning = false } - inspectCmd := fmt.Sprintf("docker inspect -f '{{.State.Status}} {{.State.Running}}' %s", shellEscape(containerID)) + runtimeCommand = strings.TrimSpace(runtimeCommand) + if runtimeCommand == "" { + runtimeCommand = "docker" + } + inspectCmd := fmt.Sprintf("%s inspect -f '{{.State.Status}} {{.State.Running}}' %s", runtimeCommand, shellEscape(containerID)) var lastOut string var lastExit int @@ -257,7 +275,7 @@ func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, rout TargetID: routing.TargetID, }) if err != nil { - return map[string]interface{}{"confirmed": false, "method": "docker_inspect", "command": inspectCmd, "note": err.Error()} + return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": err.Error()} } lastExit = res.ExitCode lastOut = strings.TrimSpace(res.Stdout + "\n" + res.Stderr) @@ -275,7 +293,7 @@ func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, rout if res.ExitCode == 0 && observedStatus != "" && observedRunning == expectRunning { return map[string]interface{}{ "confirmed": true, - "method": "docker_inspect", + "method": runtimeCommand + "_inspect", "command": inspectCmd, "expected": map[string]interface{}{"running": expectRunning}, "observed": map[string]interface{}{"status": observedStatus, "running": observedRunning}, @@ -292,14 +310,14 @@ func (e *PulseToolExecutor) verifyDockerContainerState(ctx context.Context, rout default: } } - return map[string]interface{}{"confirmed": false, "method": "docker_inspect", "command": inspectCmd, "note": "context canceled", "raw": lastOut, "exit_code": lastExit} + return map[string]interface{}{"confirmed": false, "method": runtimeCommand + "_inspect", "command": inspectCmd, "note": "context canceled", "raw": lastOut, "exit_code": lastExit} case <-waitTimer.C: } } return map[string]interface{}{ "confirmed": false, - "method": "docker_inspect", + "method": runtimeCommand + "_inspect", "command": inspectCmd, "expected": map[string]interface{}{"running": expectRunning}, "raw": lastOut, diff --git a/internal/api/actions.go b/internal/api/actions.go index 728bd2894..d824eef2c 100644 --- a/internal/api/actions.go +++ b/internal/api/actions.go @@ -377,6 +377,20 @@ func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Re writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness")) return } + if err := h.validateActionExecutionPolicy(store, record); err != nil { + if unified.IsPermanentActionExecutionRefusal(err) { + if failed, persistErr := recordRefusedActionExecution(store, record, actor, now, err); persistErr == nil { + h.publishActionCompleted(failed) + } else { + writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(persistErr, "Failed to persist refused action execution")) + return + } + writeActionExecutionApplyError(w, err) + return + } + writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy")) + return + } started, startEvent, err := unified.BeginActionExecution(record, actor, now) if err != nil { @@ -454,6 +468,24 @@ func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified. return nil } +func (h *ResourceHandlers) validateActionExecutionPolicy(store unified.ResourceStore, record unified.ActionAuditRecord) error { + if store == nil { + return errors.New("action audit store unavailable") + } + normalized, err := unified.NormalizeActionAuditRecord(record) + if err != nil { + return err + } + state, found, err := store.GetResourceOperatorState(normalized.Request.ResourceID) + if err != nil || !found { + return err + } + if state.NeverAutoRemediate { + return unified.ErrResourceRemediationLocked + } + return nil +} + func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord, actor string, now time.Time, reason error) (unified.ActionAuditRecord, error) { failed, event, err := unified.RefuseActionExecution(record, reason, actor, now) if err != nil { @@ -524,6 +556,8 @@ func writeActionExecutionApplyError(w http.ResponseWriter, err error) { writeJSONError(w, http.StatusConflict, "action_dry_run_only", "Action plan is dry-run only and cannot be executed") case errors.Is(err, unified.ErrActionPlanDrift): writeJSONError(w, http.StatusConflict, "action_plan_drift", "Action plan no longer matches the current resource contract; re-plan before executing") + case errors.Is(err, unified.ErrResourceRemediationLocked): + writeJSONError(w, http.StatusConflict, "resource_remediation_locked", "Resource is operator-locked against automated remediation") default: writeJSONError(w, http.StatusInternalServerError, "action_execution_failed", sanitizeErrorForClient(err, "Action execution failed")) } @@ -536,7 +570,8 @@ func writeActionExecutionPersistError(w http.ResponseWriter, err error) { errors.Is(err, unified.ErrActionExecutionFinal), errors.Is(err, unified.ErrActionNotExecuting), errors.Is(err, unified.ErrActionPlanExpired), - errors.Is(err, unified.ErrActionDryRunOnly): + errors.Is(err, unified.ErrActionDryRunOnly), + errors.Is(err, unified.ErrResourceRemediationLocked): writeActionExecutionApplyError(w, err) default: writeJSONError(w, http.StatusInternalServerError, "action_execution_persist_failed", sanitizeErrorForClient(err, "Failed to persist action execution")) diff --git a/internal/api/agent_capabilities.go b/internal/api/agent_capabilities.go index 12c5bf8b3..4e6aee21a 100644 --- a/internal/api/agent_capabilities.go +++ b/internal/api/agent_capabilities.go @@ -463,14 +463,14 @@ var agentCapabilitiesManifest = AgentCapabilitiesManifest{ }, { Name: "execute_action", - Description: "Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.", + Description: "Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.", Category: "action", Method: http.MethodPost, Path: "/api/actions/{actionId}/execute", Scope: "ai:execute", RequestBodyShape: "{ reason?: string }", ResponseShape: "ActionExecutionResponse", - ErrorCodes: []string{"missing_id", "invalid_id", "invalid_action_execution", "action_not_found", "action_not_approved", "action_already_executing", "action_execution_final", "action_dry_run_only", "action_plan_expired", "action_plan_drift", "action_executor_unavailable"}, + ErrorCodes: []string{"missing_id", "invalid_id", "invalid_action_execution", "action_not_found", "action_not_approved", "action_already_executing", "action_execution_final", "action_dry_run_only", "action_plan_expired", "action_plan_drift", "resource_remediation_locked", "action_executor_unavailable"}, }, }, } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 6b54f29e4..8da70909b 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -15066,6 +15066,7 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T) "action_execution_encode_failed": true, "action_execution_failed": true, "action_not_executing": true, + "action_policy_validation_failed": true, "action_plan_validation_failed": true, "resource_registry_unavailable": true, } diff --git a/internal/api/docker_container_action_executor.go b/internal/api/docker_container_action_executor.go new file mode 100644 index 000000000..6a8279b53 --- /dev/null +++ b/internal/api/docker_container_action_executor.go @@ -0,0 +1,258 @@ +package api + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/safety" + unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" +) + +const dockerContainerLifecycleHandler = "docker.container.lifecycle" + +type dockerActionAgentCommander interface { + ExecuteCommand(ctx context.Context, agentID string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) +} + +type dockerContainerActionExecutor struct { + resources *ResourceHandlers + agents dockerActionAgentCommander +} + +func newDockerContainerActionExecutor(resources *ResourceHandlers, agents dockerActionAgentCommander) ActionExecutor { + if resources == nil || agents == nil { + return nil + } + return dockerContainerActionExecutor{resources: resources, agents: agents} +} + +func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) { + record, err := unified.NormalizeActionAuditRecord(record) + if err != nil { + return nil, err + } + operation := strings.TrimSpace(record.Request.CapabilityName) + if !isDockerContainerLifecycleOperation(operation) { + return nil, fmt.Errorf("unsupported docker container lifecycle capability %q", operation) + } + + resource, err := e.currentDockerContainerResource(ctx, record.Request.ResourceID, operation) + if err != nil { + return nil, err + } + runtime, err := dockerContainerRuntime(resource) + if err != nil { + return nil, err + } + containerRef := dockerContainerRef(resource) + if containerRef == "" { + return nil, fmt.Errorf("docker container resource %q has no executable container id", record.Request.ResourceID) + } + agentID := strings.TrimSpace(resource.Docker.AgentID) + if agentID == "" { + return nil, fmt.Errorf("docker container resource %q is not backed by a reporting Pulse agent", record.Request.ResourceID) + } + + command := fmt.Sprintf("%s %s %s", runtime, operation, shellQuote(containerRef)) + result, err := e.agents.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{ + RequestID: record.ID, + Command: command, + ApprovalID: record.ID, + TargetType: "agent", + Timeout: 120, + }) + if err != nil { + return nil, err + } + + output := redactActionOutput(commandOutput(result)) + execution := &unified.ExecutionResult{ + Success: result.ExitCode == 0, + Output: output, + } + if result.ExitCode != 0 { + execution.ErrorMessage = strings.TrimSpace(firstNonEmpty(result.Error, output, fmt.Sprintf("container %s exited with status %d", operation, result.ExitCode))) + return execution, nil + } + + verification := e.verifyContainerState(ctx, agentID, record.ID, runtime, containerRef, operation) + execution.Verification = verification + if verification != nil && verification.Ran && !verification.Success { + execution.Success = false + execution.ErrorMessage = "container lifecycle action completed but verification did not confirm the expected state" + } + return execution, nil +} + +func (e dockerContainerActionExecutor) currentDockerContainerResource(ctx context.Context, resourceID, operation string) (unified.Resource, error) { + if e.resources == nil { + return unified.Resource{}, fmt.Errorf("resource handler unavailable") + } + registry, err := e.resources.buildRegistry(GetOrgID(ctx)) + if err != nil { + return unified.Resource{}, err + } + resource, ok := registry.Get(resourceID) + if !ok || resource == nil { + return unified.Resource{}, fmt.Errorf("resource %q is no longer present", resourceID) + } + if resource.Type != unified.ResourceTypeAppContainer || resource.Docker == nil { + return unified.Resource{}, fmt.Errorf("resource %q is not a Docker or Podman container", resourceID) + } + if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") { + return unified.Resource{}, fmt.Errorf("docker inventory for resource %q is %s", resourceID, status.Status) + } + if resource.Docker.Security != nil && resource.Docker.Security.MutatingCommandsBlocked { + reason := strings.TrimSpace(resource.Docker.Security.MutatingCommandsBlockedReason) + if reason == "" { + reason = "Docker daemon-mutating commands are disabled for this host" + } + return unified.Resource{}, fmt.Errorf("docker container lifecycle action is blocked by host policy: %s", reason) + } + if capability, ok := findDockerLifecycleCapability(resource.Capabilities, operation); !ok { + return unified.Resource{}, fmt.Errorf("resource %q does not currently advertise %s capability", resourceID, operation) + } else if capability.InternalHandler != dockerContainerLifecycleHandler { + return unified.Resource{}, fmt.Errorf("resource %q advertises %s through unsupported handler %q", resourceID, operation, capability.InternalHandler) + } + return *resource, nil +} + +func findDockerLifecycleCapability(capabilities []unified.ResourceCapability, operation string) (unified.ResourceCapability, bool) { + operation = strings.TrimSpace(operation) + for _, capability := range capabilities { + if strings.TrimSpace(capability.Name) == operation { + return capability, true + } + } + return unified.ResourceCapability{}, false +} + +func dockerContainerRuntime(resource unified.Resource) (string, error) { + if resource.Docker == nil { + return "", fmt.Errorf("docker resource metadata missing") + } + switch strings.ToLower(strings.TrimSpace(firstNonEmpty(resource.Docker.Runtime, resource.Technology))) { + case "docker": + return "docker", nil + case "podman": + return "podman", nil + default: + return "", fmt.Errorf("unsupported container runtime %q", firstNonEmpty(resource.Docker.Runtime, resource.Technology)) + } +} + +func dockerContainerRef(resource unified.Resource) string { + if resource.Docker == nil { + return "" + } + if ref := strings.TrimSpace(resource.Docker.ContainerID); ref != "" { + return ref + } + return strings.TrimSpace(resource.Name) +} + +func (e dockerContainerActionExecutor) verifyContainerState(ctx context.Context, agentID, actionID, runtime, containerRef, operation string) *unified.ActionVerificationResult { + expectRunning := operation == "start" || operation == "restart" + command := fmt.Sprintf("%s inspect -f '{{.State.Status}} {{.State.Running}}' %s", runtime, shellQuote(containerRef)) + + var lastOutput string + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + timer := time.NewTimer(500 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return &unified.ActionVerificationResult{Ran: false} + case <-timer.C: + } + } + + result, err := e.agents.ExecuteCommand(ctx, agentID, agentexec.ExecuteCommandPayload{ + RequestID: actionID + "-verify", + Command: command, + ApprovalID: actionID, + TargetType: "agent", + Timeout: 30, + }) + if err != nil { + return &unified.ActionVerificationResult{Ran: false} + } + lastOutput = redactActionOutput(commandOutput(result)) + status, running := parseDockerInspectState(lastOutput) + if result.ExitCode == 0 && status != "" && running == expectRunning { + return &unified.ActionVerificationResult{ + Ran: true, + Command: command, + Output: lastOutput, + Success: true, + RanAt: time.Now().UTC(), + } + } + } + + return &unified.ActionVerificationResult{ + Ran: true, + Command: command, + Output: lastOutput, + Success: false, + RanAt: time.Now().UTC(), + Note: fmt.Sprintf("expected running=%t", expectRunning), + } +} + +func parseDockerInspectState(output string) (string, bool) { + fields := strings.Fields(strings.ToLower(strings.TrimSpace(output))) + if len(fields) == 0 { + return "", false + } + running := false + if len(fields) > 1 { + running = fields[1] == "true" + } + return fields[0], running +} + +func isDockerContainerLifecycleOperation(operation string) bool { + switch strings.TrimSpace(operation) { + case "start", "stop", "restart": + return true + default: + return false + } +} + +func commandOutput(result *agentexec.CommandResultPayload) string { + if result == nil { + return "" + } + parts := []string{} + if stdout := strings.TrimSpace(result.Stdout); stdout != "" { + parts = append(parts, stdout) + } + if stderr := strings.TrimSpace(result.Stderr); stderr != "" { + parts = append(parts, stderr) + } + if errText := strings.TrimSpace(result.Error); errText != "" { + parts = append(parts, errText) + } + return strings.Join(parts, "\n") +} + +func redactActionOutput(output string) string { + output = strings.TrimSpace(output) + if output == "" { + return "" + } + redacted, n := safety.RedactSensitiveText(output) + if n > 0 { + return strings.TrimSpace(redacted) + fmt.Sprintf("\n\n[redacted %d sensitive value(s)]", n) + } + return output +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} diff --git a/internal/api/docker_container_action_executor_test.go b/internal/api/docker_container_action_executor_test.go new file mode 100644 index 000000000..b980635e3 --- /dev/null +++ b/internal/api/docker_container_action_executor_test.go @@ -0,0 +1,239 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/agentexec" + "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" + "github.com/rcourtman/pulse-go-rewrite/pkg/auth" +) + +type fakeDockerActionAgentCommander struct { + results []*agentexec.CommandResultPayload + calls []agentexec.ExecuteCommandPayload +} + +func (f *fakeDockerActionAgentCommander) ExecuteCommand(_ context.Context, _ string, cmd agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + f.calls = append(f.calls, cmd) + if len(f.results) == 0 { + return &agentexec.CommandResultPayload{RequestID: cmd.RequestID, Success: true, ExitCode: 0}, nil + } + result := f.results[0] + f.results = f.results[1:] + return result, nil +} + +func TestDockerContainerActionExecutorDispatchesPodmanRestartAndVerification(t *testing.T) { + now := time.Now().UTC() + h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h.SetStateProvider(resourceUnifiedSeedProvider{ + snapshot: models.StateSnapshot{LastUpdate: now}, + resources: []unified.Resource{ + dockerContainerActionResource("app-container:api", "podman", "running", now), + }, + }) + agents := &fakeDockerActionAgentCommander{results: []*agentexec.CommandResultPayload{ + {RequestID: "act_container", Success: true, ExitCode: 0, Stdout: "api restarted"}, + {RequestID: "act_container-verify", Success: true, ExitCode: 0, Stdout: "running true"}, + }} + executor := newDockerContainerActionExecutor(h, agents) + + result, err := executor.ExecuteAction(context.Background(), dockerContainerActionRecord("act_container", "app-container:api", "restart")) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if result == nil || !result.Success || result.Verification == nil || !result.Verification.Success { + t.Fatalf("result = %#v, want successful execution and verification", result) + } + if len(agents.calls) != 2 { + t.Fatalf("agent calls = %d, want dispatch and verification", len(agents.calls)) + } + if got := agents.calls[0].Command; got != "podman restart 'container-123'" { + t.Fatalf("dispatch command = %q", got) + } + if agents.calls[0].ApprovalID != "act_container" || agents.calls[0].Trusted { + t.Fatalf("dispatch approval/trust = %q/%v", agents.calls[0].ApprovalID, agents.calls[0].Trusted) + } + if got := agents.calls[1].Command; got != "podman inspect -f '{{.State.Status}} {{.State.Running}}' 'container-123'" { + t.Fatalf("verification command = %q", got) + } +} + +func TestDockerContainerActionExecutorFailsWhenCapabilityNoLongerAdvertised(t *testing.T) { + now := time.Now().UTC() + resource := dockerContainerActionResource("app-container:api", "docker", "running", now) + resource.Capabilities = nil + h := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + h.SetStateProvider(resourceUnifiedSeedProvider{ + snapshot: models.StateSnapshot{LastUpdate: now}, + resources: []unified.Resource{resource}, + }) + agents := &fakeDockerActionAgentCommander{} + executor := newDockerContainerActionExecutor(h, agents) + + result, err := executor.ExecuteAction(context.Background(), dockerContainerActionRecord("act_container", "app-container:api", "restart")) + if err == nil || !strings.Contains(err.Error(), "does not currently advertise restart capability") { + t.Fatalf("ExecuteAction err = %v, result = %#v", err, result) + } + if len(agents.calls) != 0 { + t.Fatalf("agent calls = %#v, want none", agents.calls) + } +} + +func TestHandleExecuteActionRejectsNeverAutoRemediateBeforeExecutor(t *testing.T) { + now := time.Date(2026, 5, 4, 14, 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", + }, + }, + }, + }, + }) + executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true, Output: "should not run"}} + h.SetActionExecutor(executor) + + planRec := httptest.NewRecorder() + planReq := httptest.NewRequest(http.MethodPost, "/api/actions/plan", bytes.NewBufferString(`{ + "requestId":"agent-run-locked", + "resourceId":"vm:42", + "capabilityName":"restart", + "reason":"Recover after confirmed outage", + "requestedBy":"agent:oncall-helper" + }`)) + h.HandlePlanAction(planRec, planReq) + if planRec.Code != http.StatusOK { + t.Fatalf("plan status = %d, body=%s", planRec.Code, planRec.Body.String()) + } + var plan unified.ActionPlan + if err := json.Unmarshal(planRec.Body.Bytes(), &plan); err != nil { + t.Fatalf("decode plan: %v", err) + } + + decisionRec := httptest.NewRecorder() + decisionReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved"}`)) + decisionReq.SetPathValue("id", plan.ActionID) + decisionReq = decisionReq.WithContext(auth.WithUser(decisionReq.Context(), "operator@example.com")) + h.HandleDecideAction(decisionRec, decisionReq) + if decisionRec.Code != http.StatusOK { + t.Fatalf("decision status = %d, body=%s", decisionRec.Code, decisionRec.Body.String()) + } + + store, err := h.getStore("default") + if err != nil { + t.Fatalf("get store: %v", err) + } + if err := store.SetResourceOperatorState(unified.ResourceOperatorState{ + CanonicalID: "vm:42", + NeverAutoRemediate: true, + SetAt: now, + SetBy: "operator@example.com", + }); err != nil { + t.Fatalf("SetResourceOperatorState: %v", err) + } + + executeRec := httptest.NewRecorder() + executeReq := httptest.NewRequest(http.MethodPost, "/api/actions/"+plan.ActionID+"/execute", bytes.NewBufferString(`{}`)) + executeReq.SetPathValue("id", plan.ActionID) + executeReq = executeReq.WithContext(auth.WithUser(executeReq.Context(), "operator@example.com")) + h.HandleExecuteAction(executeRec, executeReq) + if executeRec.Code != http.StatusConflict { + t.Fatalf("execute status = %d, body=%s", executeRec.Code, executeRec.Body.String()) + } + if executor.calls != 0 { + t.Fatalf("executor calls = %d, want none", executor.calls) + } + audit, ok, err := store.GetActionAudit(plan.ActionID) + if err != nil { + t.Fatalf("GetActionAudit: %v", err) + } + if !ok || audit.State != unified.ActionStateFailed || audit.Result == nil || !strings.HasPrefix(audit.Result.ErrorMessage, "resource_remediation_locked:") { + t.Fatalf("locked audit = %#v, ok=%v", audit, ok) + } +} + +func dockerContainerActionResource(id, runtime, state string, now time.Time) unified.Resource { + return unified.Resource{ + ID: id, + Type: unified.ResourceTypeAppContainer, + Technology: runtime, + Name: "api", + Status: unified.StatusOnline, + LastSeen: now, + UpdatedAt: now, + Sources: []unified.DataSource{unified.SourceDocker}, + SourceStatus: map[unified.DataSource]unified.SourceStatus{ + unified.SourceDocker: {Status: "online", LastSeen: now}, + }, + Docker: &unified.DockerData{ + AgentID: "agent-1", + ContainerID: "container-123", + ContainerState: state, + Runtime: runtime, + }, + Capabilities: []unified.ResourceCapability{ + { + Name: "restart", + Type: unified.CapabilityTypeCommon, + Description: "Restart this container", + MinimumApprovalLevel: unified.ApprovalAdmin, + Platform: runtime, + InternalHandler: dockerContainerLifecycleHandler, + }, + }, + } +} + +func dockerContainerActionRecord(actionID, resourceID, operation string) unified.ActionAuditRecord { + return unified.ActionAuditRecord{ + ID: actionID, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + State: unified.ActionStateExecuting, + Request: unified.ActionRequest{ + RequestID: "req-" + actionID, + ResourceID: resourceID, + CapabilityName: operation, + Reason: "test execution", + RequestedBy: "agent:oncall-helper", + Params: map[string]any{}, + }, + Plan: unified.ActionPlan{ + ActionID: actionID, + RequestID: "req-" + actionID, + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unified.ApprovalAdmin, + PlannedAt: time.Now().UTC().Add(-time.Minute), + ExpiresAt: time.Now().UTC().Add(time.Minute), + ResourceVersion: "resource:sha256:test", + PolicyVersion: "policy:sha256:test", + PlanHash: "sha256:test", + }, + } +} diff --git a/internal/api/router.go b/internal/api/router.go index 21d399442..3b35b9575 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -562,6 +562,9 @@ func (r *Router) setupRoutes() { // Agent execution server for AI tool use r.agentExecServer = agentexec.NewServer(r.validateAgentExecToken) + if r.resourceHandlers != nil { + r.resourceHandlers.SetActionExecutor(newDockerContainerActionExecutor(r.resourceHandlers, r.agentExecServer)) + } if r.monitor != nil { r.configureProxmoxGuestDockerDetection(r.monitor) } diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 04bba818e..c718360f2 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1176,7 +1176,8 @@ func resourceFromDockerHost(host models.DockerHost) (Resource, ResourceIdentity) Hidden: host.Hidden, PendingUninstall: host.PendingUninstall, IsLegacy: host.IsLegacy, - Command: host.Command, + Command: cloneDockerHostCommandStatus(host.Command), + Security: cloneDockerHostSecurity(host.Security), Swarm: convertSwarm(host.Swarm), NetworkInterfaces: convertInterfaces(host.NetworkInterfaces), Disks: convertDisks(host.Disks), @@ -1909,6 +1910,7 @@ func isZFSStorageType(storageType string) bool { func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHost) (Resource, ResourceIdentity) { metrics := metricsFromDockerContainer(ct) + now := time.Now().UTC() runtime := strings.TrimSpace(host.Runtime) if runtime == "" { if ct.Podman != nil { @@ -1919,6 +1921,7 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo } docker := &DockerData{ HostSourceID: host.ID, + AgentID: strings.TrimSpace(host.AgentID), ContainerID: ct.ID, Hostname: host.Hostname, Image: ct.Image, @@ -1932,6 +1935,7 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo Runtime: runtime, RuntimeVersion: host.RuntimeVersion, DockerVersion: host.DockerVersion, + Security: cloneDockerHostSecurity(host.Security), } if !ct.CreatedAt.IsZero() { docker.CreatedAt = ct.CreatedAt.UTC().Format(time.RFC3339) @@ -2004,13 +2008,14 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo } } resource := Resource{ - Type: ResourceTypeAppContainer, - Technology: runtime, - Name: ct.Name, - Status: statusFromDockerState(ct.State), - LastSeen: host.LastSeen, - UpdatedAt: time.Now().UTC(), - Metrics: metrics, + Type: ResourceTypeAppContainer, + Technology: runtime, + Name: ct.Name, + Status: statusFromDockerState(ct.State), + LastSeen: host.LastSeen, + UpdatedAt: now, + Metrics: metrics, + Capabilities: dockerContainerLifecycleCapabilities(ct, host, runtime, now), } resource.Docker = docker identity := ResourceIdentity{ @@ -2019,6 +2024,87 @@ func resourceFromDockerContainer(ct models.DockerContainer, host models.DockerHo return resource, identity } +func dockerContainerLifecycleCapabilities(ct models.DockerContainer, host models.DockerHost, runtime string, now time.Time) []ResourceCapability { + runtime = normalizeDockerLifecycleRuntime(runtime, ct.Podman != nil) + if runtime == "" { + return nil + } + if strings.TrimSpace(host.AgentID) == "" { + return nil + } + if host.Security != nil && host.Security.MutatingCommandsBlocked { + return nil + } + if host.LastSeen.IsZero() { + return nil + } + if now.IsZero() { + now = time.Now().UTC() + } else { + now = now.UTC() + } + if threshold := defaultStaleThresholds[SourceDocker]; threshold > 0 && now.Sub(host.LastSeen.UTC()) > threshold { + return nil + } + + switch strings.ToLower(strings.TrimSpace(ct.State)) { + case "running": + return dockerContainerLifecycleCapabilitySet(runtime, "stop", "restart") + case "created", "exited", "dead", "stopped": + return dockerContainerLifecycleCapabilitySet(runtime, "start") + default: + return nil + } +} + +func normalizeDockerLifecycleRuntime(runtime string, podman bool) string { + switch strings.ToLower(strings.TrimSpace(runtime)) { + case "docker": + return "docker" + case "podman": + return "podman" + case "": + if podman { + return "podman" + } + return "docker" + default: + return "" + } +} + +func dockerContainerLifecycleCapabilitySet(runtime string, names ...string) []ResourceCapability { + displayRuntime := "Docker" + if runtime == "podman" { + displayRuntime = "Podman" + } + + capabilities := make([]ResourceCapability, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + capabilities = append(capabilities, ResourceCapability{ + Name: name, + Type: CapabilityTypeCommon, + Description: fmt.Sprintf("%s this %s container through its reporting Pulse agent.", titleAction(name), displayRuntime), + MinimumApprovalLevel: ApprovalAdmin, + Platform: runtime, + InternalHandler: "docker.container.lifecycle", + }) + } + return capabilities +} + +func titleAction(action string) string { + action = strings.TrimSpace(action) + if action == "" { + return "Run" + } + return strings.ToUpper(action[:1]) + action[1:] +} + func resourceFromDockerService(service models.DockerService, host models.DockerHost) (Resource, ResourceIdentity) { now := time.Now().UTC() diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 407dbb483..33efba2a3 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -1,6 +1,7 @@ package unifiedresources import ( + "reflect" "strings" "testing" "time" @@ -38,6 +39,149 @@ func TestResourceFromProxmoxNodeIncludesTemperature(t *testing.T) { } } +func TestResourceFromDockerContainerAdvertisesLifecycleCapabilities(t *testing.T) { + now := time.Now().UTC() + host := models.DockerHost{ + ID: "docker-host-1", + AgentID: "agent-1", + Hostname: "dock1", + Runtime: "docker", + LastSeen: now, + } + + running, _ := resourceFromDockerContainer(models.DockerContainer{ + ID: "abc123", + Name: "web", + State: "running", + }, host) + if running.Docker == nil || running.Docker.AgentID != "agent-1" { + t.Fatalf("docker agent id was not projected onto container resource: %#v", running.Docker) + } + if got := capabilityNames(running.Capabilities); !reflect.DeepEqual(got, []string{"stop", "restart"}) { + t.Fatalf("running container capabilities = %#v, want stop/restart", got) + } + for _, capability := range running.Capabilities { + if capability.MinimumApprovalLevel != ApprovalAdmin { + t.Fatalf("capability %q approval = %q, want admin", capability.Name, capability.MinimumApprovalLevel) + } + if capability.Platform != "docker" || capability.InternalHandler != "docker.container.lifecycle" { + t.Fatalf("capability %q platform/handler = %q/%q", capability.Name, capability.Platform, capability.InternalHandler) + } + } + + exited, _ := resourceFromDockerContainer(models.DockerContainer{ + ID: "def456", + Name: "worker", + State: "exited", + }, host) + if got := capabilityNames(exited.Capabilities); !reflect.DeepEqual(got, []string{"start"}) { + t.Fatalf("exited container capabilities = %#v, want start", got) + } +} + +func TestResourceFromDockerContainerLifecycleCapabilitiesFailClosed(t *testing.T) { + now := time.Now().UTC() + baseHost := models.DockerHost{ + ID: "docker-host-1", + AgentID: "agent-1", + Hostname: "dock1", + Runtime: "docker", + LastSeen: now, + } + container := models.DockerContainer{ID: "abc123", Name: "web", State: "running"} + + cases := []struct { + name string + host models.DockerHost + ct models.DockerContainer + }{ + { + name: "missing agent", + host: func() models.DockerHost { + host := baseHost + host.AgentID = "" + return host + }(), + ct: container, + }, + { + name: "stale inventory", + host: func() models.DockerHost { + host := baseHost + host.LastSeen = now.Add(-10 * time.Minute) + return host + }(), + ct: container, + }, + { + name: "daemon mutation blocked", + host: func() models.DockerHost { + host := baseHost + host.Security = &models.DockerHostSecurity{MutatingCommandsBlocked: true, MutatingCommandsBlockedReason: "blocked"} + return host + }(), + ct: container, + }, + { + name: "unsupported runtime", + host: func() models.DockerHost { + host := baseHost + host.Runtime = "containerd" + return host + }(), + ct: container, + }, + { + name: "non-actionable state", + host: baseHost, + ct: models.DockerContainer{ID: "abc123", Name: "web", State: "restarting"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resource, _ := resourceFromDockerContainer(tc.ct, tc.host) + if len(resource.Capabilities) != 0 { + t.Fatalf("capabilities = %#v, want none", resource.Capabilities) + } + }) + } +} + +func TestResourceFromDockerContainerPodmanLifecycleCapabilities(t *testing.T) { + resource, _ := resourceFromDockerContainer(models.DockerContainer{ + ID: "podman-1", + Name: "api", + State: "running", + Podman: &models.DockerPodmanContainer{PodName: "apps"}, + }, models.DockerHost{ + ID: "podman-host-1", + AgentID: "agent-1", + Hostname: "podman1", + LastSeen: time.Now().UTC(), + }) + + if got := capabilityNames(resource.Capabilities); !reflect.DeepEqual(got, []string{"stop", "restart"}) { + t.Fatalf("podman capabilities = %#v, want stop/restart", got) + } + for _, capability := range resource.Capabilities { + if capability.Platform != "podman" { + t.Fatalf("capability %q platform = %q, want podman", capability.Name, capability.Platform) + } + } + if resource.Docker == nil || resource.Docker.Runtime != "podman" { + t.Fatalf("podman runtime not projected: %#v", resource.Docker) + } +} + +func capabilityNames(capabilities []ResourceCapability) []string { + names := make([]string, 0, len(capabilities)) + for _, capability := range capabilities { + names = append(names, capability.Name) + } + return names +} + func TestResourceFromProxmoxNodeStoresEndpointIPAsIPAddress(t *testing.T) { node := models.Node{ ID: "mock-cluster-minipc", diff --git a/internal/unifiedresources/clone.go b/internal/unifiedresources/clone.go index 213fe5a2e..087aea170 100644 --- a/internal/unifiedresources/clone.go +++ b/internal/unifiedresources/clone.go @@ -232,6 +232,7 @@ func cloneDockerData(in *DockerData) *DockerData { out.ObjectCreatedAt = cloneTimePtr(in.ObjectCreatedAt) out.ObjectUpdatedAt = cloneTimePtr(in.ObjectUpdatedAt) out.Command = cloneDockerHostCommandStatus(in.Command) + out.Security = cloneDockerHostSecurity(in.Security) out.Swarm = cloneDockerSwarmInfo(in.Swarm) out.NetworkInterfaces = cloneNetworkInterfaces(in.NetworkInterfaces) out.Disks = cloneDiskInfos(in.Disks) @@ -736,6 +737,15 @@ func cloneDockerHostCommandStatus(in *models.DockerHostCommandStatus) *models.Do return &out } +func cloneDockerHostSecurity(in *models.DockerHostSecurity) *models.DockerHostSecurity { + if in == nil { + return nil + } + out := *in + out.AuthorizationPlugins = append([]string(nil), in.AuthorizationPlugins...) + return &out +} + func clonePMGMailStatsMeta(in *PMGMailStatsMeta) *PMGMailStatsMeta { if in == nil { return nil diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 5a30ba91f..9bb34b8dc 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -65,6 +65,8 @@ import ( "regexp" "strings" "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" ) // readConsumerGoFiles returns the contents of all non-test .go files in the @@ -218,6 +220,35 @@ func TestNoDirectStateAccessForMigratedResources(t *testing.T) { } } +func TestCloneResourceClonesDockerSecurityPosture(t *testing.T) { + original := Resource{ + ID: "app-container:docker-host-1:web", + Type: ResourceTypeAppContainer, + Docker: &DockerData{ + HostSourceID: "docker-host-1", + Runtime: "docker", + Security: &models.DockerHostSecurity{ + AuthorizationPlugins: []string{"opa"}, + MutatingCommandsBlocked: true, + MutatingCommandsBlockedReason: "authorization plugin configured", + }, + }, + } + + cloned := cloneResource(&original) + if cloned.Docker == nil || cloned.Docker.Security == nil { + t.Fatalf("clone lost docker security posture: %#v", cloned.Docker) + } + if !reflect.DeepEqual(cloned.Docker.Security, original.Docker.Security) { + t.Fatalf("docker security posture clone = %#v, want %#v", cloned.Docker.Security, original.Docker.Security) + } + + cloned.Docker.Security.AuthorizationPlugins[0] = "changed" + if got := original.Docker.Security.AuthorizationPlugins[0]; got != "opa" { + t.Fatalf("docker security posture was aliased through clone, original plugin = %q", got) + } +} + func TestProxmoxWorkloadActionTargetsStayBackendAuthored(t *testing.T) { apiSource, err := os.ReadFile(filepath.Join("..", "api", "resources.go")) if err != nil { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index ce2418a89..ed4660eec 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -927,6 +927,7 @@ type DockerData struct { PendingUninstall bool `json:"pendingUninstall,omitempty"` IsLegacy bool `json:"isLegacy,omitempty"` Command *models.DockerHostCommandStatus `json:"command,omitempty"` + Security *models.DockerHostSecurity `json:"security,omitempty"` // Container-specific fields (populated when Resource.Type == ResourceTypeAppContainer) ContainerState string `json:"containerState,omitempty"` diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index 0098132d4..df894ad67 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -1701,6 +1701,15 @@ func (v DockerHostView) Command() *models.DockerHostCommandStatus { return &copied } +func (v DockerHostView) Security() *models.DockerHostSecurity { + if v.r == nil || v.r.Docker == nil || v.r.Docker.Security == nil { + return nil + } + copied := *v.r.Docker.Security + copied.AuthorizationPlugins = append([]string(nil), v.r.Docker.Security.AuthorizationPlugins...) + return &copied +} + // StoragePoolView wraps a storage resource. type StoragePoolView struct{ r *Resource }