mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55: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.
207 lines
8.5 KiB
Go
207 lines
8.5 KiB
Go
package agentexec
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/operationreceipt"
|
|
)
|
|
|
|
func TestDockerContainerLifecycleWireCarriesFactsNotActionTruth(t *testing.T) {
|
|
typeOf := reflect.TypeOf(DockerContainerLifecycleResultPayload{})
|
|
for _, forbidden := range []string{"Success", "Verification", "Evidence", "EvidenceClass", "Compensation", "Independent"} {
|
|
if _, ok := typeOf.FieldByName(forbidden); ok {
|
|
t.Fatalf("Docker lifecycle wire result declares canonical action truth field %q", forbidden)
|
|
}
|
|
}
|
|
payload, err := json.Marshal(DockerContainerLifecycleResultPayload{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{`"success"`, `"verification"`, `"evidence"`, `"evidence_class"`, `"compensation"`, `"independent"`} {
|
|
if jsonContains(payload, forbidden) {
|
|
t.Fatalf("Docker lifecycle wire result exposes canonical action truth key %s: %s", forbidden, payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerAgentWireAndRuntimeDeclareNoLocalActionTruthModel(t *testing.T) {
|
|
paths := []string{"docker_lifecycle_codec.go", "../hostagent/docker_lifecycle.go"}
|
|
for _, path := range paths {
|
|
source, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{"DockerContainerVerification", "ActionResultV2", "ActionVerificationTruth", "ActionEvidenceClass", "ActionCompensationTruth", `json:"verification`, `json:"evidence`, `json:"independent`} {
|
|
if strings.Contains(string(source), forbidden) {
|
|
t.Fatalf("%s declares server-owned action truth token %q", path, forbidden)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPendingDockerOperationBindsFullIdentityAndRejectsCrossTypeReuse(t *testing.T) {
|
|
server := NewServer(func(string, string, string) bool { return true })
|
|
identity := operationreceipt.Identity{AttemptID: "action.dispatch.1", ActionID: "action", OperationKind: DockerContainerOperationRestart, OperationVersion: DockerContainerLifecycleOperationVersion, RequestDigest: "sha256:" + strings.Repeat("a", 64), AgentID: "agent-1"}
|
|
key, err := server.claimPendingDockerOperation(identity, "0123456789ab")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer server.releasePendingHostOperation(key)
|
|
if _, err := server.claimPendingHostOperation(identity.AgentID, identity.AttemptID, identity.ActionID, HostUpdateOperationInstall); err == nil {
|
|
t.Fatal("same attempt identity was reused across operation kinds")
|
|
}
|
|
exact := DockerContainerLifecycleResultPayload{RequestID: identity.AttemptID, ActionID: identity.ActionID, Operation: identity.OperationKind, OperationVersion: identity.OperationVersion, RequestDigest: identity.RequestDigest, ContainerID: "0123456789ab"}
|
|
if !server.matchesPendingDockerOperation(identity.AgentID, exact) {
|
|
t.Fatal("exact durable identity did not match")
|
|
}
|
|
exact.RequestDigest = "sha256:" + strings.Repeat("b", 64)
|
|
if server.matchesPendingDockerOperation(identity.AgentID, exact) {
|
|
t.Fatal("request digest drift reached the waiter")
|
|
}
|
|
}
|
|
|
|
func jsonContains(payload []byte, value string) bool {
|
|
for i := 0; i+len(value) <= len(payload); i++ {
|
|
if string(payload[i:i+len(value)]) == value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validDockerUpdatePayload(t *testing.T) DockerContainerUpdatePayload {
|
|
t.Helper()
|
|
payload := DockerContainerUpdatePayload{
|
|
RequestID: "attempt-1",
|
|
ActionID: "action-1",
|
|
Runtime: "docker",
|
|
ContainerID: strings.Repeat("a", 12),
|
|
ExpectedImageDigest: "sha256:" + strings.Repeat("9", 64),
|
|
Timeout: 600,
|
|
}
|
|
if err := BindDockerContainerUpdatePayload(&payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func validDockerUpdateResult(t *testing.T, req DockerContainerUpdatePayload) DockerContainerUpdateResultPayload {
|
|
t.Helper()
|
|
observed := time.Date(2026, 7, 14, 12, 0, 0, 0, time.UTC)
|
|
return DockerContainerUpdateResultPayload{
|
|
RequestID: req.RequestID, ActionID: req.ActionID, Operation: req.Operation,
|
|
OperationVersion: req.OperationVersion, RequestDigest: req.RequestDigest, ContainerID: req.ContainerID,
|
|
ExecutionPhase: DockerContainerPhaseComplete, MutationStarted: true, MutationCompleted: true, ReadbackRan: true,
|
|
NewContainerID: strings.Repeat("b", 12), ContainerName: "app",
|
|
OldImageDigest: "sha256:" + strings.Repeat("1", 64), NewImageDigest: "sha256:" + strings.Repeat("2", 64),
|
|
BackupCreated: true, BackupContainer: "app_pulse_backup_20260714_120000",
|
|
After: DockerContainerLifecycleSnapshot{ContainerID: strings.Repeat("b", 12), State: "running", Running: true, ObservedAt: observed},
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerUpdatePayloadBindAndValidate(t *testing.T) {
|
|
payload := validDockerUpdatePayload(t)
|
|
if payload.Operation != DockerContainerOperationUpdate || payload.OperationVersion != DockerContainerUpdateOperationVersion {
|
|
t.Fatalf("bind did not stamp operation identity: %+v", payload)
|
|
}
|
|
if err := ValidateDockerContainerUpdatePayload(&payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tampered := payload
|
|
tampered.ExpectedImageDigest = "sha256:" + strings.Repeat("8", 64)
|
|
if err := ValidateDockerContainerUpdatePayload(&tampered); err == nil {
|
|
t.Fatal("digest mismatch after expected-image tamper was accepted")
|
|
}
|
|
|
|
badDigest := payload
|
|
badDigest.ExpectedImageDigest = "busybox:latest"
|
|
if err := BindDockerContainerUpdatePayload(&badDigest); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ValidateDockerContainerUpdatePayload(&badDigest); err == nil {
|
|
t.Fatal("non-digest expected image was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerUpdateResultContract(t *testing.T) {
|
|
req := validDockerUpdatePayload(t)
|
|
result := validDockerUpdateResult(t, req)
|
|
if err := ValidateDockerContainerUpdateResultForRequest(req, result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
completeWithoutReplacement := result
|
|
completeWithoutReplacement.NewContainerID = ""
|
|
if err := ValidateDockerContainerUpdateResultPayload(&completeWithoutReplacement); err == nil {
|
|
t.Fatal("complete phase without replacement container was accepted")
|
|
}
|
|
|
|
rollbackWithoutAttempt := result
|
|
rollbackWithoutAttempt.ExecutionPhase = DockerContainerPhaseMutate
|
|
rollbackWithoutAttempt.MutationCompleted = false
|
|
rollbackWithoutAttempt.Error = "create failed"
|
|
rollbackWithoutAttempt.RolledBack = true
|
|
rollbackWithoutAttempt.RollbackAttempted = false
|
|
if err := ValidateDockerContainerUpdateResultPayload(&rollbackWithoutAttempt); err == nil {
|
|
t.Fatal("rollback success without attempt was accepted")
|
|
}
|
|
|
|
wrongContainer := result
|
|
wrongContainer.ContainerID = strings.Repeat("c", 12)
|
|
if err := ValidateDockerContainerUpdateResultForRequest(req, wrongContainer); err == nil {
|
|
t.Fatal("result for a different container was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerUpdateWireCarriesFactsNotActionTruth(t *testing.T) {
|
|
typeOf := reflect.TypeOf(DockerContainerUpdateResultPayload{})
|
|
for _, forbidden := range []string{"Success", "Verification", "Evidence", "EvidenceClass", "Compensation", "Independent"} {
|
|
if _, ok := typeOf.FieldByName(forbidden); ok {
|
|
t.Fatalf("Docker update wire result declares canonical action truth field %q", forbidden)
|
|
}
|
|
}
|
|
payload, err := json.Marshal(DockerContainerUpdateResultPayload{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, forbidden := range []string{`"success"`, `"verification"`, `"evidence"`, `"evidence_class"`, `"compensation"`, `"independent"`} {
|
|
if jsonContains(payload, forbidden) {
|
|
t.Fatalf("Docker update wire result exposes canonical action truth key %s: %s", forbidden, payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDockerContainerUpdateOperationQueryEnvelope(t *testing.T) {
|
|
req := validDockerUpdatePayload(t)
|
|
result := validDockerUpdateResult(t, req)
|
|
encoded, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
identity := DockerContainerUpdateOperationIdentity("agent-1", req)
|
|
terminalAt := result.After.ObservedAt.Add(time.Second)
|
|
query := operationreceipt.QueryResult{
|
|
Version: operationreceipt.ProtocolVersion,
|
|
Status: operationreceipt.QueryFoundTerminal,
|
|
Record: &operationreceipt.Record{
|
|
Identity: identity, State: operationreceipt.StateTerminal,
|
|
AcceptedAt: result.After.ObservedAt.Add(-time.Minute), StartedAt: result.After.ObservedAt.Add(-30 * time.Second), TerminalAt: terminalAt,
|
|
ResultKind: DockerContainerUpdateReceiptKind, ResultVersion: DockerContainerUpdateReceiptVersion, Result: encoded,
|
|
},
|
|
}
|
|
if err := ValidateOperationQueryResultForIdentity(query, identity, terminalAt.Add(time.Minute)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
query.Record.ResultKind = DockerContainerLifecycleReceiptKind
|
|
if err := ValidateOperationQueryResultForIdentity(query, identity, terminalAt.Add(time.Minute)); err == nil {
|
|
t.Fatal("lifecycle envelope was accepted for an update identity")
|
|
}
|
|
}
|