Pulse/internal/agentexec/docker_update_codec.go
rcourtman 3c778e2b26 Restore one-click Docker container updates through the typed action plane
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.
2026-07-14 12:19:04 +01:00

196 lines
9.1 KiB
Go

package agentexec
import (
"fmt"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
const (
DockerContainerUpdateOperationVersion = 1
DockerContainerUpdateReceiptKind = "pulse.docker_container_update_result"
DockerContainerUpdateReceiptVersion = 1
maxDockerContainerNameLength = 256
maxDockerImageDigestLength = 256
// Image pulls dominate update time; the module's own overall budget is 15
// minutes, so the transport bound leaves headroom without being unbounded.
maxDockerContainerUpdateTimeoutSeconds = 1800
defaultDockerContainerUpdateTimeoutSeconds = 900
)
func DecodeDockerContainerUpdatePayload(data []byte) (DockerContainerUpdatePayload, error) {
var payload DockerContainerUpdatePayload
if err := decodeStrictDockerLifecycle(data, &payload); err != nil {
return DockerContainerUpdatePayload{}, err
}
if err := ValidateDockerContainerUpdatePayload(&payload); err != nil {
return DockerContainerUpdatePayload{}, err
}
return payload, nil
}
func DecodeDockerContainerUpdateResultPayload(data []byte) (DockerContainerUpdateResultPayload, error) {
var payload DockerContainerUpdateResultPayload
if err := decodeStrictDockerLifecycle(data, &payload); err != nil {
return DockerContainerUpdateResultPayload{}, err
}
if err := ValidateDockerContainerUpdateResultPayload(&payload); err != nil {
return DockerContainerUpdateResultPayload{}, err
}
return payload, nil
}
func BindDockerContainerUpdatePayload(payload *DockerContainerUpdatePayload) error {
if payload == nil {
return fmt.Errorf("docker container update payload is required")
}
payload.Operation = DockerContainerOperationUpdate
payload.OperationVersion = DockerContainerUpdateOperationVersion
digest, err := dockerContainerUpdateRequestDigest(*payload)
if err != nil {
return err
}
payload.RequestDigest = digest
return nil
}
func dockerContainerUpdateRequestDigest(payload DockerContainerUpdatePayload) (string, error) {
return operationreceipt.DigestCanonicalJSON(struct {
ActionID string `json:"action_id"`
Operation string `json:"operation"`
OperationVersion int `json:"operation_version"`
Runtime string `json:"runtime"`
ContainerID string `json:"container_id"`
ExpectedImageDigest string `json:"expected_image_digest"`
}{
strings.TrimSpace(payload.ActionID), strings.TrimSpace(payload.Operation), payload.OperationVersion,
strings.ToLower(strings.TrimSpace(payload.Runtime)), strings.ToLower(strings.TrimSpace(payload.ContainerID)),
strings.ToLower(strings.TrimSpace(payload.ExpectedImageDigest)),
})
}
func ValidateDockerContainerUpdatePayload(payload *DockerContainerUpdatePayload) error {
if payload == nil {
return fmt.Errorf("docker container update payload is required")
}
payload.RequestID = strings.TrimSpace(payload.RequestID)
payload.ActionID = strings.TrimSpace(payload.ActionID)
payload.Operation = strings.TrimSpace(payload.Operation)
payload.Runtime = strings.ToLower(strings.TrimSpace(payload.Runtime))
payload.ContainerID = strings.ToLower(strings.TrimSpace(payload.ContainerID))
payload.ExpectedImageDigest = strings.ToLower(strings.TrimSpace(payload.ExpectedImageDigest))
if payload.RequestID == "" || len(payload.RequestID) > maxRequestIDLength || payload.ActionID == "" || len(payload.ActionID) > maxRequestIDLength {
return fmt.Errorf("invalid docker update request or action id")
}
if payload.Operation != DockerContainerOperationUpdate {
return fmt.Errorf("unsupported docker container update operation %q", payload.Operation)
}
if payload.OperationVersion != DockerContainerUpdateOperationVersion {
return fmt.Errorf("unsupported docker container update operation version %d", payload.OperationVersion)
}
if payload.Runtime != "docker" && payload.Runtime != "podman" {
return fmt.Errorf("unsupported container runtime %q", payload.Runtime)
}
if !dockerContainerIDPattern.MatchString(payload.ContainerID) {
return fmt.Errorf("container id must be an immutable hexadecimal id")
}
if !hostUpdateInventoryHashPattern.MatchString(payload.ExpectedImageDigest) {
return fmt.Errorf("invalid docker update expected image digest")
}
expectedDigest, err := dockerContainerUpdateRequestDigest(*payload)
if err != nil {
return err
}
if payload.RequestDigest != expectedDigest {
return fmt.Errorf("docker container update request digest mismatch")
}
if payload.Timeout < 0 || payload.Timeout > maxDockerContainerUpdateTimeoutSeconds {
return fmt.Errorf("docker container update timeout must be between 0 and %d seconds", maxDockerContainerUpdateTimeoutSeconds)
}
if payload.Timeout == 0 {
payload.Timeout = defaultDockerContainerUpdateTimeoutSeconds
}
return nil
}
func ValidateDockerContainerUpdateResultPayload(result *DockerContainerUpdateResultPayload) error {
if result == nil {
return fmt.Errorf("docker container update result is required")
}
result.RequestID = strings.TrimSpace(result.RequestID)
result.ActionID = strings.TrimSpace(result.ActionID)
result.Operation = strings.TrimSpace(result.Operation)
result.RequestDigest = strings.TrimSpace(result.RequestDigest)
result.ContainerID = strings.ToLower(strings.TrimSpace(result.ContainerID))
result.NewContainerID = strings.ToLower(strings.TrimSpace(result.NewContainerID))
result.ContainerName = strings.TrimSpace(result.ContainerName)
result.OldImageDigest = strings.TrimSpace(result.OldImageDigest)
result.NewImageDigest = strings.TrimSpace(result.NewImageDigest)
result.BackupContainer = strings.TrimSpace(result.BackupContainer)
result.ExecutionPhase = strings.TrimSpace(result.ExecutionPhase)
result.Error = strings.TrimSpace(result.Error)
if result.RequestID == "" || len(result.RequestID) > maxRequestIDLength || result.ActionID == "" || len(result.ActionID) > maxRequestIDLength {
return fmt.Errorf("invalid docker update result identity")
}
if result.Operation != DockerContainerOperationUpdate {
return fmt.Errorf("unsupported docker update result operation %q", result.Operation)
}
if result.OperationVersion != DockerContainerUpdateOperationVersion || !dockerContainerIDPattern.MatchString(result.ContainerID) || !hostUpdateInventoryHashPattern.MatchString(result.RequestDigest) {
return fmt.Errorf("invalid docker update result binding")
}
if result.ExecutionPhase != DockerContainerPhasePreflight && result.ExecutionPhase != DockerContainerPhaseMutate && result.ExecutionPhase != DockerContainerPhaseVerify && result.ExecutionPhase != DockerContainerPhaseComplete {
return fmt.Errorf("unsupported docker update execution phase %q", result.ExecutionPhase)
}
if result.NewContainerID != "" && !dockerContainerIDPattern.MatchString(result.NewContainerID) {
return fmt.Errorf("docker update result has invalid replacement container id")
}
if len(result.Error) > 1024 || len(result.ContainerName) > maxDockerContainerNameLength || len(result.BackupContainer) > maxDockerContainerNameLength {
return fmt.Errorf("docker update result exceeds bounded contract")
}
if len(result.OldImageDigest) > maxDockerImageDigestLength || len(result.NewImageDigest) > maxDockerImageDigestLength {
return fmt.Errorf("docker update result digest exceeds bounded contract")
}
if result.MutationCompleted && !result.MutationStarted {
return fmt.Errorf("completed docker update mutation requires mutation start")
}
if result.RolledBack && !result.RollbackAttempted {
return fmt.Errorf("docker update rollback success requires a rollback attempt")
}
if result.RollbackAttempted && !result.MutationStarted {
return fmt.Errorf("docker update rollback requires mutation start")
}
if result.ReadbackRan && result.After.ObservedAt.IsZero() {
return fmt.Errorf("docker update readback requires an observation")
}
if result.ExecutionPhase == DockerContainerPhaseComplete {
if result.Error != "" || !result.MutationCompleted || result.NewContainerID == "" {
return fmt.Errorf("complete docker update requires a replacement container and no error")
}
}
return nil
}
func DockerContainerUpdateOperationIdentity(agentID string, payload DockerContainerUpdatePayload) operationreceipt.Identity {
return operationreceipt.Identity{AttemptID: payload.RequestID, ActionID: payload.ActionID, OperationKind: payload.Operation, OperationVersion: payload.OperationVersion, RequestDigest: payload.RequestDigest, AgentID: strings.TrimSpace(agentID)}
}
func ValidateDockerContainerUpdateResultForRequest(req DockerContainerUpdatePayload, result DockerContainerUpdateResultPayload) error {
if err := ValidateDockerContainerUpdatePayload(&req); err != nil {
return err
}
if err := ValidateDockerContainerUpdateResultPayload(&result); err != nil {
return err
}
if result.RequestID != req.RequestID || result.ActionID != req.ActionID || result.Operation != req.Operation || result.OperationVersion != req.OperationVersion || result.RequestDigest != req.RequestDigest {
return fmt.Errorf("docker update result identity mismatch")
}
if result.ContainerID != req.ContainerID {
return fmt.Errorf("docker update result container mismatch")
}
if result.After.ContainerID != "" && result.NewContainerID != "" && !strings.EqualFold(result.After.ContainerID, result.NewContainerID) {
return fmt.Errorf("docker update after-state container mismatch")
}
return nil
}