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:
@@ -85,11 +85,25 @@ func scheduledContainer(t *testing.T, schedule string) *declaration.Container {
|
||||
|
||||
func TestInstallingAScheduleDoesNotRunItAndReportsCurrent(t *testing.T) {
|
||||
// The deliberate inversion of run-once: the schedule is state that is present, so the apply is
|
||||
// current as soon as it is recorded — nothing is run, and no runtime is even probed.
|
||||
var calls []string
|
||||
// current as soon as it is recorded. Installing it ensures the pinned image is present (a
|
||||
// scheduled step is never run at apply, so nothing else pulls it), but it NEVER runs the
|
||||
// container — no `docker run` fires it, which is the invariant that matters (novox/hq ADR 0053).
|
||||
var fired bool // a `docker run` — the container was started
|
||||
var pulled bool // an image reported present was pulled anyway
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
calls = append(calls, name+" "+strings.Join(args, " "))
|
||||
return "", errors.New("installing a schedule must not run any command")
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil // a container runtime answers the apply's probe
|
||||
case "image":
|
||||
return "", nil // `image inspect`: the pinned image is already present
|
||||
case "pull":
|
||||
pulled = true
|
||||
return "", nil
|
||||
case "run":
|
||||
fired = true
|
||||
return "", errors.New("installing a schedule must not run the container")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"`+pinned+`","schedule":"0 3 * * *"}
|
||||
@@ -99,8 +113,11 @@ func TestInstallingAScheduleDoesNotRunItAndReportsCurrent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("installing a schedule failed the apply: %v", err)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
t.Errorf("installing a schedule ran commands, so it did more than record state: %v", calls)
|
||||
if fired {
|
||||
t.Error("installing a schedule ran the container — it installs state, it does not fire it")
|
||||
}
|
||||
if pulled {
|
||||
t.Error("installing a schedule pulled an image it had just found present")
|
||||
}
|
||||
if report.Outcomes[0].Action != "created" {
|
||||
t.Errorf("an installed schedule was not reported created: %+v", report.Outcomes[0])
|
||||
@@ -114,7 +131,7 @@ func TestInstallingAScheduleDoesNotRunItAndReportsCurrent(t *testing.T) {
|
||||
t.Error("an installed schedule recorded no marker, so a re-apply cannot tell it is unchanged")
|
||||
}
|
||||
|
||||
// And a re-apply of the same declaration is unchanged and still runs nothing.
|
||||
// And a re-apply of the same declaration is unchanged and still fires nothing.
|
||||
report2, _, err := Apply(context.Background(), archHost(t), d, state, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -122,6 +139,59 @@ func TestInstallingAScheduleDoesNotRunItAndReportsCurrent(t *testing.T) {
|
||||
if report2.Changed() {
|
||||
t.Errorf("re-installing the same schedule reported a change: %+v", report2.Outcomes)
|
||||
}
|
||||
if fired {
|
||||
t.Error("re-installing a schedule ran the container")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallingAScheduleEnsuresItsImageIsPresentWithoutStartingIt(t *testing.T) {
|
||||
// A scheduled step is never run at apply, so `docker run` — which is what pulls a service's or a
|
||||
// run-once step's image — never fetches it. Without an explicit pull the image is absent from the
|
||||
// node until the first scheduled fire, which then pays the whole pull latency and, until it runs,
|
||||
// leaves tooling that expects the image present after apply looking at a node without it. So the
|
||||
// apply ensures the image present: when it is absent it is pulled, and still nothing is run.
|
||||
imagePresent := false
|
||||
var pulledImage string
|
||||
var fired bool
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "image": // `image inspect <image>`
|
||||
if imagePresent {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("no such image")
|
||||
case "pull":
|
||||
pulledImage = args[len(args)-1]
|
||||
imagePresent = true // a real runtime leaves the image present after a pull
|
||||
return "", nil
|
||||
case "run":
|
||||
fired = true
|
||||
return "", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"`+pinned+`","schedule":"0 3 * * *"}
|
||||
]}`)
|
||||
|
||||
report, state, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("installing a schedule whose image was absent failed the apply: %v", err)
|
||||
}
|
||||
if pulledImage != pinned {
|
||||
t.Errorf("installing a schedule did not pull its pinned image (pulled %q, want %q)", pulledImage, pinned)
|
||||
}
|
||||
if fired {
|
||||
t.Error("installing a schedule ran the container — ensuring the image is present is not running it")
|
||||
}
|
||||
if report.Outcomes[0].Action != "created" {
|
||||
t.Errorf("an installed schedule was not reported created: %+v", report.Outcomes[0])
|
||||
}
|
||||
if _, ok := state.Find("sync"); !ok {
|
||||
t.Fatal("an installed schedule was not recorded as applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAScheduledStepDoesNotGateWhatFollows(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user