mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
3c778e2b26
v6.1.0-rc.1 retired the legacy update endpoints before a replacement existed, so the UI's Update button failed with an internal-jargon 410 (issue #1564). This lands the replacement end to end: update_container is a typed agentexec operation with its own strict codec, durable receipts, and a request digest bound to the image digest the plan observed; the unified agent bridges execution to the Docker module's existing pull/backup/recreate/verify/rollback implementation (which now reports rollback attempt and outcome); and the container action executor plans, dispatches, and reconciles the operation with declared backup/rollback compensation truth. Containers advertise an admin-approval update capability while an image update with a stated current digest is detected. The legacy endpoints stay retired but return actionable copy. Proven live against a Colima daemon: single-container update, the issue-1564 shared-network-namespace update, and the full UI journey (Update button, governed review, approve, run) all completed with the namespace preserved and the backup retained.
536 lines
23 KiB
Go
536 lines
23 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(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(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) 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(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(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(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(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(ctx, attempt.AgentID, identity)
|
|
if err != nil {
|
|
return nil, unified.ActionDispatchReceipt{}, false, err
|
|
}
|
|
if query.Status != operationreceipt.QueryFoundTerminal {
|
|
return nil, unified.ActionDispatchReceipt{}, false, nil
|
|
}
|
|
receivedAt := time.Now().UTC()
|
|
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(resource); err != nil {
|
|
return unavailableDockerActionReadiness(operation, "command_agent_disconnected", "Docker / Podman command agent is not connected.")
|
|
}
|
|
agentID, _ := e.connectedDockerCommandAgentID(resource)
|
|
liveCapability, supported := e.agents.(agentOperationReceiptCapability)
|
|
if !supported || liveCapability.AgentOperationReceiptVersion(agentID) != operationreceipt.ProtocolVersion {
|
|
return unavailableDockerActionReadiness(operation, "operation_receipt_unsupported", "This agent does not support durable typed-operation receipts.")
|
|
}
|
|
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(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 := strings.TrimSpace(resource.Docker.AgentID); agentID != "" && e.agents.IsAgentConnected(agentID) {
|
|
return agentID, nil
|
|
}
|
|
if agentID, ok := e.agents.GetAgentForHost(strings.TrimSpace(resource.Docker.Hostname)); ok {
|
|
agentID = strings.TrimSpace(agentID)
|
|
if agentID != "" && e.agents.IsAgentConnected(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, "'", "'\"'\"'") + "'"
|
|
}
|