Add governed Docker and Podman lifecycle actions

Refs #1034
This commit is contained in:
rcourtman 2026-06-12 21:17:58 +01:00
parent 94a2b7dc37
commit 6725d6a784
25 changed files with 916 additions and 23 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`,

View file

@ -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},

View file

@ -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,

View file

@ -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(),
}
}

View file

@ -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,

View file

@ -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"))

View file

@ -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"},
},
},
}

View file

@ -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,
}

View file

@ -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, "'", "'\"'\"'") + "'"
}

View file

@ -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",
},
}
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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",

View file

@ -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

View file

@ -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 {

View file

@ -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"`

View file

@ -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 }