mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
parent
94a2b7dc37
commit
6725d6a784
25 changed files with 916 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
258
internal/api/docker_container_action_executor.go
Normal file
258
internal/api/docker_container_action_executor.go
Normal 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, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
239
internal/api/docker_container_action_executor_test.go
Normal file
239
internal/api/docker_container_action_executor_test.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue