apply: ensure a scheduled container's image is present at apply, without running it

A schedule: container (ADR 0053) is installed as present state and never run at apply — the
Scheduler fires it later on its cadence. But a service or run-once container only gets its
image as a side effect of docker run, so a scheduled step's image was not pulled until its
first scheduled fire: absent from the node right after a successful apply, so the first run
paid the whole pull latency and tooling that expects the image present after apply found it
missing.

applyContainer now probes the runtime and ensures the pinned image present for a scheduled
step before recording it. A new ensureImage helper inspects the image and pulls it only if
absent, then reads back (ADR 0018). Ensuring an image is not running it: no docker run fires
the container, so the no-run invariant of ADR 0053 holds. The runtime probe, previously
skipped for a schedule, now runs because a pull needs it — the schedule.go comment is updated
to match.

Tests: the install-does-not-run test is extended to allow the image-ensure while asserting no
fire and no needless pull; a new test applies a scheduled container whose image is absent and
asserts it is pulled and still not started. go build, go vet, go test ./... all pass.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-07 02:23:46 +02:00
parent a37acf77ae
commit e5af6bb58e
3 changed files with 120 additions and 16 deletions
+40 -7
View File
@@ -948,21 +948,30 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner, c
out := begin(r)
want := containerSpec(r)
cri, err := containerRuntime(ctx, run)
if err != nil {
return out, fmt.Errorf("%w, so nothing can be said about %q", err, r.Name)
}
// A scheduled step is state that is present, not a container to start (novox/hq ADR 0053).
// Installing it records the schedule and reports the node current at once — the deliberate
// inversion of run-once, which gates. The recurring run is fired by the host's Scheduler off the
// clock, re-established from this declaration each apply, and NEVER here — so installing does not
// run the container and does not even need a runtime present. Checked before the runtime probe
// for exactly that reason.
// RUN the container.
//
// It does, however, ensure the pinned image is present now. A service or a run-once container
// gets its image as a side effect of `docker run`; a scheduled step is never run at apply, so
// without this its image would be absent from the node until the first scheduled fire — which
// would pay the whole pull latency then, and leave tooling that expects the image present after
// apply looking at a node that does not have it. Ensuring the image is not running it, so the
// no-run invariant holds.
if r.Schedule != "" {
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
return out, err
}
return applySchedule(r, want, previous)
}
cri, err := containerRuntime(ctx, run)
if err != nil {
return out, fmt.Errorf("%w, so nothing can be said about %q", err, r.Name)
}
if r.RunOnce {
return applyRunOnce(ctx, r, run, cri, want, previous)
}
@@ -1130,6 +1139,30 @@ func foregroundRunArgs(r *declaration.Container, want string) []string {
return args
}
// ensureImage makes the pinned image present on the node without running anything.
//
// A service or a run-once container gets its image as a side effect of `docker run` — the first run
// pulls it. A scheduled step is installed but deliberately never run at apply (novox/hq ADR 0053), so
// nothing would pull its image until the first scheduled fire: the image is absent from the node
// right after a successful apply, the first run pays the whole pull latency, and tooling that expects
// the image present after apply finds it missing. This fetches the same bytes `docker run` would, and
// stops short of starting the container.
//
// Idempotent, and it reads back (novox/hq ADR 0018): an image already present is left as is, and a
// pull that reported success but left nothing there is a failure, not a convergence.
func ensureImage(ctx context.Context, cri, image string, run Runner) error {
if _, err := run(ctx, cri, "image", "inspect", image); err == nil {
return nil
}
if _, err := run(ctx, cri, "pull", image); err != nil {
return fmt.Errorf("pulling image %s: %w", image, err)
}
if _, err := run(ctx, cri, "image", "inspect", image); err != nil {
return fmt.Errorf("image %s is not present after pulling it: %w", image, err)
}
return nil
}
// applySchedule installs a scheduled step: it records the schedule as present and reports the node
// current, without running anything (novox/hq ADR 0053).
//