Files
pulse/internal/api/docker_container_action_executor.go
T
Richard Courtman ccea5ba761 Fail actions stranded without agent completion evidence and add force-fail (#1649)
An action that reached executing had exactly one way out: a terminal
operation receipt from the agent. Expiry skips executing rows on purpose,
and reconciliation bailed for every non-terminal query answer, so a Docker
update whose agent restarted mid-pull sat in executing forever with no
operator route out of it.

The agent-side receipt store rewrites accepted and started receipts to
interrupted on every Open(), and nothing can move an interrupted or
tombstoned receipt back to terminal. An identity-correlated answer of
either kind is therefore proof the operation will never report, and the
three executors now settle the action immediately as inconclusive with a
message telling the operator Pulse cannot confirm the effect and the
resource needs checking by hand.

A not_found answer, or a receipt still merely accepted or started, may
still be completed, so those keep waiting and only settle once the
dispatch attempt is older than one hour, the same threshold the
pulse-intelligence telemetry already uses to call an executing action
stuck. Every typed operation timeout is far shorter than that, so an
in-flight mutation is never cut short. A transport error answering the
query is still not evidence and preserves receipt_pending unchanged.

RecoverExecutingActions drives all of this on the existing two-minute
recovery loop, so rows already wedged before the upgrade heal themselves
without anyone touching them.

For the residue that reconciliation cannot reach, an agent that was
reinstalled or a legacy executing row with no dispatch attempt, POST
/api/actions/{id}/force-fail writes the same inconclusive terminal truth
under an operator attribution. It never touches the transport, refuses
anything already terminal, and is gated on admin plus settings:write on
top of the execute capability check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 12:14:28 +01:00

559 lines
24 KiB
Go

package api
import (
"context"
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const (
dockerContainerLifecycleHandler = "docker.container.lifecycle"
dockerContainerUpdateHandler = "docker.container.update"
)
type dockerContainerActionExecutor struct {
resources *ResourceHandlers
agents actionAgentCommander
observer dockerContainerPostconditionObserver
}
type dockerContainerPostconditionObservation struct {
ObserverID string
TrustDomain string
Method string
Snapshot agentexec.DockerContainerLifecycleSnapshot
ReceivedAt time.Time
}
type dockerContainerPostconditionObserver interface {
ObserveDockerContainer(context.Context, string, string) (dockerContainerPostconditionObservation, error)
}
type dockerContainerLifecycleAgentCommander interface {
ExecuteDockerContainerLifecycle(context.Context, string, agentexec.DockerContainerLifecyclePayload) (*agentexec.DockerContainerLifecycleResultPayload, error)
}
type dockerContainerUpdateAgentCommander interface {
ExecuteDockerContainerUpdate(context.Context, string, agentexec.DockerContainerUpdatePayload) (*agentexec.DockerContainerUpdateResultPayload, error)
}
func isDockerContainerUpdateOperation(operation string) bool {
return strings.TrimSpace(operation) == "update"
}
// dockerContainerOperationHandler returns the internal handler a capability
// must advertise for the requested operation, so an update request can never
// ride a lifecycle capability or vice versa.
func dockerContainerOperationHandler(operation string) string {
if isDockerContainerUpdateOperation(operation) {
return dockerContainerUpdateHandler
}
return dockerContainerLifecycleHandler
}
func (e dockerContainerActionExecutor) BindActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (unified.ActionDispatchAttempt, error) {
if isDockerContainerUpdateOperation(record.Request.CapabilityName) {
resource, err := e.currentDockerContainerResource(ctx, record.Request.ResourceID, record.Request.CapabilityName)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
runtime, err := dockerContainerRuntime(resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
req, err := dockerContainerUpdateRequest(attempt.ID, record.ID, runtime, resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
return unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, AgentID: agentID})
}
operation, err := dockerAgentLifecycleOperation(record.Request.CapabilityName)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
resource, err := e.currentDockerContainerResource(ctx, record.Request.ResourceID, record.Request.CapabilityName)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
runtime, err := dockerContainerRuntime(resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
if err != nil {
return unified.ActionDispatchAttempt{}, err
}
req := dockerContainerLifecycleRequest(attempt.ID, record.ID, operation, runtime, resource)
if err := agentexec.BindDockerContainerLifecyclePayload(&req); err != nil {
return unified.ActionDispatchAttempt{}, err
}
return unified.BindActionDispatchAttempt(attempt, unified.ActionDispatchBinding{OperationKind: req.Operation, OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, AgentID: agentID})
}
func newDockerContainerActionExecutor(resources *ResourceHandlers, agents actionAgentCommander) ActionExecutor {
if resources == nil || agents == nil {
return nil
}
return dockerContainerActionExecutor{resources: resources, agents: agents}
}
func (e dockerContainerActionExecutor) ActionHandlerNames() []string {
return []string{dockerContainerLifecycleHandler, dockerContainerUpdateHandler}
}
func (e dockerContainerActionExecutor) ActionDispatchOperationKinds() []string {
return []string{
agentexec.DockerContainerOperationStart,
agentexec.DockerContainerOperationStop,
agentexec.DockerContainerOperationRestart,
agentexec.DockerContainerOperationUpdate,
}
}
func (e dockerContainerActionExecutor) ExecuteAction(ctx context.Context, record unified.ActionAuditRecord) (*unified.ExecutionResult, error) {
attempt, ok := actionlifecycle.DispatchAttemptFromContext(ctx)
if !ok || attempt.ActionID != record.ID {
return nil, fmt.Errorf("committed action dispatch authority is required")
}
record, err := unified.NormalizeActionAuditRecord(record)
if err != nil {
return nil, err
}
operation := strings.TrimSpace(record.Request.CapabilityName)
if isDockerContainerUpdateOperation(operation) {
return e.executeDockerContainerUpdate(ctx, record, attempt)
}
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
}
if dockerContainerRef(resource) == "" {
return nil, fmt.Errorf("docker container resource %q has no executable container id", record.Request.ResourceID)
}
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
if err != nil {
return nil, err
}
typedAgents, ok := e.agents.(dockerContainerLifecycleAgentCommander)
if !ok {
return nil, fmt.Errorf("typed docker container lifecycle agent is unavailable")
}
agentOperation, err := dockerAgentLifecycleOperation(operation)
if err != nil {
return nil, err
}
req := dockerContainerLifecycleRequest(attempt.ID, record.ID, agentOperation, runtime, resource)
if err := agentexec.BindDockerContainerLifecyclePayload(&req); err != nil {
return nil, err
}
if agentexec.DockerContainerLifecycleOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
return nil, fmt.Errorf("docker container lifecycle dispatch binding drift")
}
result, err := typedAgents.ExecuteDockerContainerLifecycle(agentCommandContext(ctx), agentID, req)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("docker container lifecycle agent returned no result")
}
receivedAt := time.Now().UTC()
if err := agentexec.ValidateDockerContainerLifecycleResultForRequest(req, *result); err != nil {
return nil, fmt.Errorf("invalid docker container lifecycle agent result: %w", err)
}
var independent *dockerContainerPostconditionObservation
if e.observer != nil {
if observation, observeErr := e.observer.ObserveDockerContainer(ctx, record.ID, req.ContainerID); observeErr == nil {
independent = &observation
}
}
return dockerContainerExecutionResult(record.Request.ResourceID, agentID, req, *result, independent, receivedAt)
}
func (e dockerContainerActionExecutor) executeDockerContainerUpdate(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (*unified.ExecutionResult, error) {
operation := strings.TrimSpace(record.Request.CapabilityName)
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
}
if dockerContainerRef(resource) == "" {
return nil, fmt.Errorf("docker container resource %q has no executable container id", record.Request.ResourceID)
}
agentID, err := e.connectedDockerCommandAgentID(ctx, resource)
if err != nil {
return nil, err
}
typedAgents, ok := e.agents.(dockerContainerUpdateAgentCommander)
if !ok {
return nil, fmt.Errorf("typed docker container update agent is unavailable")
}
req, err := dockerContainerUpdateRequest(attempt.ID, record.ID, runtime, resource)
if err != nil {
return nil, err
}
if agentexec.DockerContainerUpdateOperationIdentity(agentID, req) != (operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}) {
return nil, fmt.Errorf("docker container update dispatch binding drift")
}
result, err := typedAgents.ExecuteDockerContainerUpdate(agentCommandContext(ctx), agentID, req)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("docker container update agent returned no result")
}
receivedAt := time.Now().UTC()
if err := agentexec.ValidateDockerContainerUpdateResultForRequest(req, *result); err != nil {
return nil, fmt.Errorf("invalid docker container update agent result: %w", err)
}
var independent *dockerContainerPostconditionObservation
if e.observer != nil && result.NewContainerID != "" {
if observation, observeErr := e.observer.ObserveDockerContainer(ctx, record.ID, result.NewContainerID); observeErr == nil {
independent = &observation
}
}
return dockerContainerUpdateExecutionResult(record.Request.ResourceID, agentID, *result, independent, receivedAt)
}
func dockerContainerUpdateRequest(attemptID, actionID, runtime string, resource unified.Resource) (agentexec.DockerContainerUpdatePayload, error) {
expectedDigest := ""
if resource.Docker != nil && resource.Docker.UpdateStatus != nil {
expectedDigest = strings.ToLower(strings.TrimSpace(resource.Docker.UpdateStatus.CurrentDigest))
}
if expectedDigest == "" {
return agentexec.DockerContainerUpdatePayload{}, fmt.Errorf("resource %q has no current image digest to bind the update against", resource.ID)
}
req := agentexec.DockerContainerUpdatePayload{
RequestID: attemptID, ActionID: actionID, Runtime: runtime,
ContainerID: dockerContainerRef(resource), ExpectedImageDigest: expectedDigest,
}
if err := agentexec.BindDockerContainerUpdatePayload(&req); err != nil {
return agentexec.DockerContainerUpdatePayload{}, err
}
return req, nil
}
func (e dockerContainerActionExecutor) ReconcileActionDispatch(ctx context.Context, record unified.ActionAuditRecord, attempt unified.ActionDispatchAttempt) (*unified.ExecutionResult, unified.ActionDispatchReceipt, bool, error) {
querier, ok := e.agents.(operationReceiptQuerier)
if !ok {
return nil, unified.ActionDispatchReceipt{}, false, nil
}
identity := operationreceipt.Identity{AttemptID: attempt.ID, ActionID: attempt.ActionID, OperationKind: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, AgentID: attempt.AgentID}
query, err := querier.QueryAgentOperation(agentCommandContext(ctx), attempt.AgentID, identity)
if err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
receivedAt := time.Now().UTC()
if query.Status != operationreceipt.QueryFoundTerminal {
if !strandedActionDispatchClassifiable(query.Status) {
return nil, unified.ActionDispatchReceipt{}, false, nil
}
if err := agentexec.ValidateOperationQueryResultForIdentity(query, identity, receivedAt); err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
result, receipt, stranded := strandedActionDispatch(query, record, attempt, receivedAt)
return result, receipt, stranded, nil
}
if err := agentexec.ValidateOperationQueryResultForIdentity(query, identity, receivedAt); err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
if attempt.OperationKind == agentexec.DockerContainerOperationUpdate {
result, err := agentexec.DecodeDockerContainerUpdateResultPayload(query.Record.Result)
if err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
var independent *dockerContainerPostconditionObservation
if e.observer != nil && result.NewContainerID != "" {
if observation, observeErr := e.observer.ObserveDockerContainer(ctx, record.ID, result.NewContainerID); observeErr == nil {
independent = &observation
}
}
execution, err := dockerContainerUpdateExecutionResult(record.Request.ResourceID, attempt.AgentID, result, independent, receivedAt)
if err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
receipt := unified.ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: record.ID, TransportRequestID: attempt.ID, ReceivedAt: receivedAt}
return execution, receipt, true, nil
}
result, err := agentexec.DecodeDockerContainerLifecycleResultPayload(query.Record.Result)
if err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
req := agentexec.DockerContainerLifecyclePayload{RequestID: attempt.ID, ActionID: record.ID, Operation: attempt.OperationKind, OperationVersion: attempt.OperationVersion, RequestDigest: attempt.RequestDigest, Runtime: "docker", ContainerID: result.ContainerID, ExpectedState: result.Before.State, ExpectedStartedAt: result.Before.StartedAt}
var independent *dockerContainerPostconditionObservation
if e.observer != nil {
if observation, observeErr := e.observer.ObserveDockerContainer(ctx, record.ID, result.ContainerID); observeErr == nil {
independent = &observation
}
}
execution, err := dockerContainerExecutionResult(record.Request.ResourceID, attempt.AgentID, req, result, independent, receivedAt)
if err != nil {
return nil, unified.ActionDispatchReceipt{}, false, err
}
receipt := unified.ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: record.ID, TransportRequestID: attempt.ID, ReceivedAt: receivedAt}
return execution, receipt, true, nil
}
func (e dockerContainerActionExecutor) CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness {
operation := strings.TrimSpace(req.CapabilityName)
capability, ok := findDockerLifecycleCapability(resource.Capabilities, operation)
if !ok || capability.InternalHandler != dockerContainerOperationHandler(operation) {
return unified.ResourceActionReadiness{}
}
readiness := unified.ResourceActionReadiness{Name: operation, Available: true}
if e.agents == nil {
return unavailableDockerActionReadiness(operation, "command_agent_unavailable", "Docker / Podman command execution is not available.")
}
if isDockerContainerUpdateOperation(operation) {
if _, ok := e.agents.(dockerContainerUpdateAgentCommander); !ok {
return unavailableDockerActionReadiness(operation, "typed_operation_unavailable", "Typed Docker / Podman update execution is not available.")
}
} else if _, ok := e.agents.(dockerContainerLifecycleAgentCommander); !ok {
return unavailableDockerActionReadiness(operation, "typed_operation_unavailable", "Typed Docker / Podman lifecycle execution is not available.")
}
if _, err := e.executableDockerContainerResource(ctx, resource, operation); err != nil {
return unavailableDockerActionReadiness(operation, dockerActionUnavailableReasonCode(err), dockerActionUnavailableReason(err))
}
if _, err := e.connectedDockerCommandAgentID(ctx, resource); err != nil {
return unavailableDockerActionReadiness(operation, "command_agent_disconnected", "Docker / Podman command agent is not connected.")
}
agentID, _ := e.connectedDockerCommandAgentID(ctx, resource)
if liveAgentOperationReceiptVersion(ctx, e.agents, agentID) != operationreceipt.ProtocolVersion {
return unavailableDockerActionReadiness(operation, "operation_receipt_unsupported", "The Pulse agent on this host cannot run reviewed actions: it is on an older version, or its durable state directory is unavailable. Update the agent, or check the agent logs if it is already current, then retry.")
}
return readiness
}
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)
}
return e.executableDockerContainerResource(ctx, *resource, operation)
}
func (e dockerContainerActionExecutor) executableDockerContainerResource(_ context.Context, resource unified.Resource, operation string) (unified.Resource, error) {
if resource.Type != unified.ResourceTypeAppContainer || resource.Docker == nil {
return unified.Resource{}, fmt.Errorf("resource %q is not a Docker or Podman container", resource.ID)
}
if status := resource.SourceStatus[unified.SourceDocker]; strings.EqualFold(strings.TrimSpace(status.Status), "stale") || strings.EqualFold(strings.TrimSpace(status.Status), "offline") || strings.EqualFold(strings.TrimSpace(status.Status), "missing") {
return unified.Resource{}, fmt.Errorf("docker inventory for resource %q is %s", resource.ID, status.Status)
}
if resource.Docker.Security != nil && resource.Docker.Security.MutatingCommandsBlocked {
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", resource.ID, operation)
} else if capability.InternalHandler != dockerContainerOperationHandler(operation) {
return unified.Resource{}, fmt.Errorf("resource %q advertises %s through unsupported handler %q", resource.ID, operation, capability.InternalHandler)
}
return resource, nil
}
func (e dockerContainerActionExecutor) connectedDockerCommandAgentID(ctx context.Context, resource unified.Resource) (string, error) {
if e.agents == nil {
return "", fmt.Errorf("docker container command agent is not connected")
}
if resource.Docker == nil {
return "", fmt.Errorf("docker resource metadata missing")
}
if agentID, ok := commandAgentForToken(ctx, e.agents, resource.Docker.TokenID); ok {
return strings.TrimSpace(agentID), nil
}
// When telemetry names a token, it is the immutable session binding. Do
// not fall back to a different identity or hostname after rotation.
if strings.TrimSpace(resource.Docker.TokenID) != "" {
return "", fmt.Errorf("docker container command agent is not connected")
}
if agentID := strings.TrimSpace(resource.Docker.AgentID); agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
return agentID, nil
}
if agentID, ok := commandAgentForHost(ctx, e.agents, strings.TrimSpace(resource.Docker.Hostname)); ok {
agentID = strings.TrimSpace(agentID)
if agentID != "" && isAgentCommandConnected(ctx, e.agents, agentID) {
return agentID, nil
}
}
return "", fmt.Errorf("docker container command agent is not connected")
}
func unavailableDockerActionReadiness(operation, reasonCode, reason string) unified.ResourceActionReadiness {
return unified.ResourceActionReadiness{
Name: strings.TrimSpace(operation),
Available: false,
ReasonCode: strings.TrimSpace(reasonCode),
Reason: strings.TrimSpace(reason),
}
}
func dockerActionUnavailableReasonCode(err error) string {
if err == nil {
return "unavailable"
}
message := strings.ToLower(err.Error())
switch {
case strings.Contains(message, "not a docker or podman container"):
return "unsupported_resource"
case strings.Contains(message, "inventory") && (strings.Contains(message, "stale") || strings.Contains(message, "offline") || strings.Contains(message, "missing")):
return "stale_inventory"
case strings.Contains(message, "blocked by host policy"):
return "host_policy_blocked"
case strings.Contains(message, "does not currently advertise"):
return "capability_unavailable"
case strings.Contains(message, "unsupported handler"):
return "unsupported_handler"
default:
return "unavailable"
}
}
func dockerActionUnavailableReason(err error) string {
switch dockerActionUnavailableReasonCode(err) {
case "unsupported_resource":
return "Resource is not a Docker or Podman container."
case "stale_inventory":
return "Docker / Podman inventory is not fresh enough to run lifecycle actions."
case "host_policy_blocked":
return "Docker / Podman host policy blocks mutating lifecycle actions."
case "capability_unavailable":
return "Pulse does not currently advertise a fresh command capability for this container."
case "unsupported_handler":
return "This container action is not routed through the supported lifecycle executor."
default:
return "Docker / Podman lifecycle action is not currently available."
}
}
func findDockerLifecycleCapability(capabilities []unified.ResourceCapability, operation string) (unified.ResourceCapability, bool) {
operation = strings.TrimSpace(operation)
for _, capability := range capabilities {
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 ""
}
func dockerAgentLifecycleOperation(operation string) (string, error) {
switch strings.TrimSpace(operation) {
case "start", agentexec.DockerContainerOperationStart:
return agentexec.DockerContainerOperationStart, nil
case "stop", agentexec.DockerContainerOperationStop:
return agentexec.DockerContainerOperationStop, nil
case "restart", agentexec.DockerContainerOperationRestart:
return agentexec.DockerContainerOperationRestart, nil
default:
return "", fmt.Errorf("unsupported docker container lifecycle capability %q", operation)
}
}
func dockerContainerLifecycleRequest(attemptID, actionID, operation, runtime string, resource unified.Resource) agentexec.DockerContainerLifecyclePayload {
startedAt := time.Time{}
if resource.Docker != nil && resource.Docker.StartedAt != nil {
startedAt = resource.Docker.StartedAt.UTC()
}
return agentexec.DockerContainerLifecyclePayload{
RequestID: attemptID, ActionID: actionID, Operation: operation, Runtime: runtime,
ContainerID: dockerContainerRef(resource), ExpectedState: strings.ToLower(strings.TrimSpace(resource.Docker.ContainerState)), ExpectedStartedAt: startedAt, Timeout: 120,
}
}
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, "'", "'\"'\"'") + "'"
}