Pulse/internal/agentexec/docker_lifecycle_codec.go
2026-07-12 12:11:16 +01:00

201 lines
9.1 KiB
Go

package agentexec
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
)
const (
DockerContainerLifecycleOperationVersion = 1
DockerContainerLifecycleReceiptKind = "pulse.docker_container_lifecycle_result"
DockerContainerLifecycleReceiptVersion = 1
)
var dockerContainerIDPattern = regexp.MustCompile(`^[a-f0-9]{12,64}$`)
func decodeStrictDockerLifecycle(data []byte, target any) error {
if len(bytes.TrimSpace(data)) == 0 {
return fmt.Errorf("docker lifecycle payload is empty")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
if err == nil {
return fmt.Errorf("docker lifecycle payload contains trailing JSON")
}
return fmt.Errorf("docker lifecycle payload contains trailing data: %w", err)
}
return nil
}
func DecodeDockerContainerLifecyclePayload(data []byte) (DockerContainerLifecyclePayload, error) {
var payload DockerContainerLifecyclePayload
if err := decodeStrictDockerLifecycle(data, &payload); err != nil {
return DockerContainerLifecyclePayload{}, err
}
if err := ValidateDockerContainerLifecyclePayload(&payload); err != nil {
return DockerContainerLifecyclePayload{}, err
}
return payload, nil
}
func DecodeDockerContainerLifecycleResultPayload(data []byte) (DockerContainerLifecycleResultPayload, error) {
var payload DockerContainerLifecycleResultPayload
if err := decodeStrictDockerLifecycle(data, &payload); err != nil {
return DockerContainerLifecycleResultPayload{}, err
}
if err := ValidateDockerContainerLifecycleResultPayload(&payload); err != nil {
return DockerContainerLifecycleResultPayload{}, err
}
return payload, nil
}
func BindDockerContainerLifecyclePayload(payload *DockerContainerLifecyclePayload) error {
if payload == nil {
return fmt.Errorf("docker container lifecycle payload is required")
}
payload.OperationVersion = DockerContainerLifecycleOperationVersion
digest, err := dockerContainerLifecycleRequestDigest(*payload)
if err != nil {
return err
}
payload.RequestDigest = digest
return nil
}
func dockerContainerLifecycleRequestDigest(payload DockerContainerLifecyclePayload) (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"`
ExpectedState string `json:"expected_state"`
ExpectedStartedAt time.Time `json:"expected_started_at,omitempty"`
}{
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.ExpectedState)), payload.ExpectedStartedAt.UTC(),
})
}
func ValidateDockerContainerLifecyclePayload(payload *DockerContainerLifecyclePayload) error {
if payload == nil {
return fmt.Errorf("docker container lifecycle 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.ExpectedState = strings.ToLower(strings.TrimSpace(payload.ExpectedState))
payload.ExpectedStartedAt = payload.ExpectedStartedAt.UTC()
if payload.RequestID == "" || len(payload.RequestID) > maxRequestIDLength || payload.ActionID == "" || len(payload.ActionID) > maxRequestIDLength {
return fmt.Errorf("invalid docker lifecycle request or action id")
}
if payload.Operation != DockerContainerOperationStart && payload.Operation != DockerContainerOperationStop && payload.Operation != DockerContainerOperationRestart {
return fmt.Errorf("unsupported docker container lifecycle operation %q", payload.Operation)
}
if payload.OperationVersion != DockerContainerLifecycleOperationVersion {
return fmt.Errorf("unsupported docker container lifecycle 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 payload.ExpectedState == "" || len(payload.ExpectedState) > 32 {
return fmt.Errorf("expected container state is required")
}
expectedDigest, err := dockerContainerLifecycleRequestDigest(*payload)
if err != nil {
return err
}
if payload.RequestDigest != expectedDigest {
return fmt.Errorf("docker container lifecycle request digest mismatch")
}
if payload.Timeout < 0 || payload.Timeout > 300 {
return fmt.Errorf("docker container lifecycle timeout must be between 0 and 300 seconds")
}
if payload.Timeout == 0 {
payload.Timeout = 120
}
return nil
}
func ValidateDockerContainerLifecycleResultPayload(result *DockerContainerLifecycleResultPayload) error {
if result == nil {
return fmt.Errorf("docker container lifecycle 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.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 lifecycle result identity")
}
if result.Operation != DockerContainerOperationStart && result.Operation != DockerContainerOperationStop && result.Operation != DockerContainerOperationRestart {
return fmt.Errorf("unsupported docker lifecycle result operation %q", result.Operation)
}
if result.OperationVersion != DockerContainerLifecycleOperationVersion || !dockerContainerIDPattern.MatchString(result.ContainerID) || !hostUpdateInventoryHashPattern.MatchString(result.RequestDigest) {
return fmt.Errorf("invalid docker lifecycle result binding")
}
if result.ExecutionPhase != DockerContainerPhasePreflight && result.ExecutionPhase != DockerContainerPhaseMutate && result.ExecutionPhase != DockerContainerPhaseVerify && result.ExecutionPhase != DockerContainerPhaseComplete {
return fmt.Errorf("unsupported docker lifecycle execution phase %q", result.ExecutionPhase)
}
if len(result.Error) > 1024 || result.Before.RestartCount < 0 || result.After.RestartCount < 0 {
return fmt.Errorf("docker lifecycle result exceeds bounded contract")
}
for _, snapshot := range []DockerContainerLifecycleSnapshot{result.Before, result.After} {
if snapshot.ContainerID != "" && !dockerContainerIDPattern.MatchString(strings.ToLower(strings.TrimSpace(snapshot.ContainerID))) {
return fmt.Errorf("docker lifecycle result has invalid container id")
}
if !snapshot.ObservedAt.IsZero() && snapshot.ObservedAt.Location() != time.UTC {
return fmt.Errorf("docker lifecycle observation timestamp must be UTC")
}
}
if result.MutationCompleted && !result.MutationStarted {
return fmt.Errorf("completed docker lifecycle mutation requires mutation start")
}
if result.ReadbackRan && result.After.ObservedAt.IsZero() {
return fmt.Errorf("docker lifecycle readback requires an observation")
}
return nil
}
func DockerContainerLifecycleOperationIdentity(agentID string, payload DockerContainerLifecyclePayload) 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 ValidateDockerContainerLifecycleResultForRequest(req DockerContainerLifecyclePayload, result DockerContainerLifecycleResultPayload) error {
if err := ValidateDockerContainerLifecyclePayload(&req); err != nil {
return err
}
if err := ValidateDockerContainerLifecycleResultPayload(&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 || result.ContainerID != req.ContainerID {
return fmt.Errorf("docker lifecycle result identity mismatch")
}
if result.Before.ContainerID != "" && !strings.EqualFold(result.Before.ContainerID, req.ContainerID) {
return fmt.Errorf("docker lifecycle before-state container mismatch")
}
if result.After.ContainerID != "" && !strings.EqualFold(result.After.ContainerID, req.ContainerID) {
return fmt.Errorf("docker lifecycle after-state container mismatch")
}
return nil
}