Restore Docker update preflight through unified agent

This commit is contained in:
rcourtman
2026-08-23 08:35:26 +01:00
parent ea5c105ff2
commit 434e1448ff
7 changed files with 64 additions and 10 deletions
+10
View File
@@ -1526,6 +1526,16 @@ func (b *lateBoundDockerUpdater) TypedContainerUpdate(ctx context.Context, runti
return updater.TypedContainerUpdate(ctx, runtime, containerID, expectedImageDigest, progress)
}
func (b *lateBoundDockerUpdater) TypedContainerUpdatePreflight(ctx context.Context, runtime, containerID, expectedImageDigest string) error {
b.mu.RLock()
updater := b.updater
b.mu.RUnlock()
if updater == nil {
return agentexec.NewActionPreflightError(agentexec.ActionRefusalCapabilityUnavailable, fmt.Errorf("docker module is not running on this agent"))
}
return updater.TypedContainerUpdatePreflight(ctx, runtime, containerID, expectedImageDigest)
}
func initDockerWithRetry(ctx context.Context, cfg dockeragent.Config, logger *zerolog.Logger) RunnableCloser {
return initModuleWithRetry(ctx, logger, "docker_agent", "Docker", "Docker not available, will retry", func() (RunnableCloser, error) {
return newDockerAgent(cfg)
+25 -3
View File
@@ -2301,10 +2301,16 @@ func TestSecureAgentStateDir(t *testing.T) {
type stubTypedContainerUpdater struct {
calls int
preflightCalls int
lifecycleInspects int
lifecycleMutations int
}
func (s *stubTypedContainerUpdater) TypedContainerUpdatePreflight(context.Context, string, string, string) error {
s.preflightCalls++
return nil
}
func (s *stubTypedContainerUpdater) TypedContainerUpdate(context.Context, string, string, string, func(string)) (agentexec.DockerContainerUpdateOutcome, error) {
s.calls++
return agentexec.DockerContainerUpdateOutcome{Success: true}, nil
@@ -2322,19 +2328,35 @@ func (s *stubTypedContainerUpdater) MutateDockerContainerLifecycle(context.Conte
func TestLateBoundDockerUpdaterBridgesModuleWhenItComesUp(t *testing.T) {
bridge := &lateBoundDockerUpdater{}
containerID := strings.Repeat("a", 12)
expectedImageDigest := "sha256:" + strings.Repeat("1", 64)
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err == nil {
t.Fatal("bridge without a docker module accepted a preflight")
} else if got := agentexec.ActionPreflightReasonCode(err, agentexec.ActionRefusalTargetPreconditionFailed); got != agentexec.ActionRefusalCapabilityUnavailable {
t.Fatalf("bridge without a docker module refusal = %q, want %q", got, agentexec.ActionRefusalCapabilityUnavailable)
}
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err == nil {
t.Fatal("bridge without a docker module accepted an update")
}
bridge.set(struct{}{}) // non-implementing candidates must not install
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err == nil {
if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err == nil {
t.Fatal("bridge accepted a preflight after a non-implementing candidate was offered")
}
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err == nil {
t.Fatal("bridge accepted an update after a non-implementing candidate was offered")
}
stub := &stubTypedContainerUpdater{}
bridge.set(stub)
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", strings.Repeat("a", 12), "sha256:"+strings.Repeat("1", 64), nil); err != nil {
if err := bridge.TypedContainerUpdatePreflight(context.Background(), "docker", containerID, expectedImageDigest); err != nil {
t.Fatalf("bridge preflight with an installed module refused: %v", err)
}
if stub.preflightCalls != 1 {
t.Fatalf("expected one delegated preflight call, got %d", stub.preflightCalls)
}
if _, err := bridge.TypedContainerUpdate(context.Background(), "docker", containerID, expectedImageDigest, nil); err != nil {
t.Fatalf("bridge with an installed module refused: %v", err)
}
if stub.calls != 1 {
@@ -2623,6 +2623,18 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Docker update preflight and execution share one late-bound capability
The unified agent's late-bound Docker updater now carries the read-only typed
container-update preflight and the corresponding execution method in one
required interface. The bridge delegates both methods to the running Docker /
Podman module and fails closed while that module is unavailable. This prevents
the command client from advertising a false `agent_capability_unavailable`
refusal merely because the process-lifetime bridge omitted the preflight
method, and makes future bridge drift a compile-time failure. The bridge proof
in `cmd/pulse-agent/main_test.go` covers unavailable, invalid-candidate, and
connected-module states for both preflight and execution.
### Monitoring task limits do not change lifecycle authority
The monitoring-owned process-wide scheduled-task limiter may bound host and
@@ -690,6 +690,16 @@ tokens, and path-normalization variants.
## Current State
### Docker update preflight remains read-only across the unified-agent bridge
The `pulse-agent` late-bound Docker updater delegates the typed, read-only
container-update preflight through the same required capability as execution.
The preflight checks runtime identity, container identity, and expected image
digest without mutating the workload; adding it to the bridge restores the
existing approval boundary and grants no new command, inventory, environment,
mount, file, process, or guest-inspection authority. An unavailable Docker /
Podman module continues to fail closed.
### Historical credential containment is prerelease-blocking
The `historical-credential-containment` release gate now owns the cross-repo
+2 -3
View File
@@ -751,11 +751,10 @@ func (c *CommandClient) preflightDockerLifecycle(ctx context.Context, payload ag
}
func (c *CommandClient) preflightDockerUpdate(ctx context.Context, payload agentexec.DockerContainerUpdatePayload) (bool, string) {
preflighter, ok := c.dockerUpdater.(DockerContainerUpdatePreflighter)
if !ok || preflighter == nil {
if c.dockerUpdater == nil {
return false, agentexec.ActionRefusalCapabilityUnavailable
}
if err := preflighter.TypedContainerUpdatePreflight(ctx, payload.Runtime, payload.ContainerID, payload.ExpectedImageDigest); err != nil {
if err := c.dockerUpdater.TypedContainerUpdatePreflight(ctx, payload.Runtime, payload.ContainerID, payload.ExpectedImageDigest); err != nil {
return false, agentexec.ActionPreflightReasonCode(err, agentexec.ActionRefusalTargetPreconditionFailed)
}
return true, ""
@@ -160,6 +160,10 @@ type stubDockerUpdater struct {
calls *int
}
func (stubDockerUpdater) TypedContainerUpdatePreflight(context.Context, string, string, string) error {
return nil
}
func (s stubDockerUpdater) TypedContainerUpdate(_ context.Context, _, _, _ string, _ func(string)) (agentexec.DockerContainerUpdateOutcome, error) {
if s.calls != nil {
*s.calls++
+1 -4
View File
@@ -18,11 +18,8 @@ import (
// missing, runtime mismatch, preflight drift) as opposed to update failures,
// which arrive inside the outcome.
type DockerContainerUpdater interface {
TypedContainerUpdate(ctx context.Context, runtime, containerID, expectedImageDigest string, progress func(string)) (agentexec.DockerContainerUpdateOutcome, error)
}
type DockerContainerUpdatePreflighter interface {
TypedContainerUpdatePreflight(ctx context.Context, runtime, containerID, expectedImageDigest string) error
TypedContainerUpdate(ctx context.Context, runtime, containerID, expectedImageDigest string, progress func(string)) (agentexec.DockerContainerUpdateOutcome, error)
}
func (c *CommandClient) handleDockerContainerUpdate(ctx context.Context, conn *websocket.Conn, payload agentexec.DockerContainerUpdatePayload) {