Files
Nicolas De Loof 0a64ff09a8 fix: address copilot-pull-request-reviewer's 3 findings on the hook-runner plan
A GitHub Copilot PR review (docker/compose#14221#pullrequestreview-5300897216)
found three real issues in the reconciliation-plan-based hook runners, all
confirmed by direct reproduction against a live daemon before being fixed:

- **Hook runners excluded from discovery, stale runners persist.**
  collectObservedState's single getContainers call requires ConfigHashLabel
  (getDefaultFilters), which hook runners deliberately never carry -- so they
  never reached the classification loop that populates HookContainers, and
  planPreStartHookRunners never saw a stale runner to purge. Reproduced: a
  failed hook run followed by a second `up` failed with "container name ...
  already in use" instead of recovering. Fixed by listing hook containers
  separately, by project+hook label alone (new getHookContainers), and
  merging them in before classification. Locked in by a new e2e scenario,
  TestPreStartHookRetryAfterFailureSucceeds, that reproduces the exact
  failure-then-retry sequence.

- **Prepared hook runners misclassified as service replicas.** start's
  containers.filter(isService(name), isNotOneOff) has no reason to exclude a
  hook runner: it carries the same project/service labels as a real replica
  and no one-off label either. Added isNotHookContainer to the filter chain
  in startService, so a hook runner is never counted as (or started as) a
  surviving replica.

- **Per-replica hooks create runners before unsupported configuration is
  rejected.** per_replica is not yet supported (#14259): runPreStart
  validates every hook up front and rejects the whole service before
  executing any of them, but planPreStartHookRunners had no equivalent
  check, so create/up would plan (and leave behind) runners for a service
  whose hooks could never run. Mirrored the same up-front, all-or-nothing
  validation in planPreStartHookRunners.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-30 12:03:10 +02:00

308 lines
13 KiB
Go

//go:build e2e
/*
Copyright 2023 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package e2e
import (
"testing"
"time"
)
// probeVolume materializes the "read a file from the project's data volume"
// action the pre_start scenarios use to observe what the hooks wrote.
func probeVolume(s *Scenario, args ...string) Action {
return DockerCmd(append([]string{"run", "--rm", "-v", s.Project() + "_data:/mnt", "alpine"}, args...)...)
}
func TestPostStartHookInError(t *testing.T) {
NewScenario(t, "a failing post_start hook must fail up, reporting the hook's exit status").
Step("up fails on the hook error",
ComposeCmd("up", "-d").MayFail(),
ExitCode(1),
OutputContains("test hook exited with status 127"))
}
func TestPostStartHookSuccess(t *testing.T) {
NewScenario(t, "a post_start hook must run after the service starts, without failing up").
Step("up runs the hook and leaves the service running",
ComposeCmd("up", "-d"),
ServiceState("test", "running"))
}
func TestPreStopHookSuccess(t *testing.T) {
s := NewScenario(t, "a pre_stop hook must run in the container before it is stopped")
s.Step("up starts the service",
ComposeCmd("up", "-d"),
ServiceState("sample", "running")).
Step("stop runs the hook before halting the container",
ComposeCmd("stop"),
ServiceState("sample", "exited")).
Step("the hook's write is visible in the volume",
probeVolume(s, "cat", "/mnt/log.txt"),
OutputContains("In the pre-stop"))
}
func TestPreStopHookInError(t *testing.T) {
NewScenario(t, "a failing pre_stop hook must fail the down, reporting the hook's exit status").
Step("up starts the service",
ComposeCmd("up", "-d"),
ServiceState("sample", "running")).
Step("down fails on the hook error",
ComposeCmd("down", "-t", "0").MayFail(),
ExitCode(1),
OutputContains("sample hook exited with status 127"))
}
func TestPreStopHookSuccessWithPreviousStop(t *testing.T) {
NewScenario(t, "stopping a single service must run its pre_stop hook and only halt that service").
Step("up starts both services",
ComposeCmd("up", "-d"),
ServiceState("sample", "running"),
ServiceState("test", "running")).
Step("stop on the hooked service succeeds",
ComposeCmd("stop", "sample"),
ServiceState("sample", "exited"),
ServiceState("test", "running"))
}
func TestPostStartAndPreStopHook(t *testing.T) {
NewScenario(t, "post_start and pre_stop hooks in one project must not interfere with up").
Step("up starts both services and runs the post_start hook",
ComposeCmd("up", "-d"),
ServiceState("sample", "running"),
ServiceState("test", "running"))
}
func TestPreStartHookSuccess(t *testing.T) {
NewScenario(t, "a pre_start hook must run before the service, which reads what the hook prepared").
Step("up waits for the service, started after the hook",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("the service saw the file the hook wrote",
ComposeCmd("logs", "sample"),
OutputContains("initialized"))
}
func TestPreStartHookInError(t *testing.T) {
NewScenario(t, "a failing pre_start hook must fail up and leave the service created but not started").
Step("up fails, reporting the hook's exit code",
ComposeCmd("up", "-d").MayFail(),
ExitCode(1),
OutputContains("pre_start"),
OutputContains("17"),
ServiceState("sample", "created"))
}
// A retained runner from a failed run must not block the next one: it is a
// stale hook container the reconciler has to observe and purge before
// creating a fresh one under the same deterministic name, or the second up
// fails with a container-name conflict instead of recovering.
func TestPreStartHookRetryAfterFailureSucceeds(t *testing.T) {
s := NewScenario(t, "up must recover from a previously failed hook by purging its stale runner")
s.Step("first up fails on the hook",
ComposeCmd("up", "-d").MayFail(),
ExitCode(1),
OutputContains("pre_start"),
ServiceState("sample", "created")).
Step("second up purges the stale runner and succeeds",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second),
ServiceState("sample", "running"))
}
func TestPreStartHookBuildInheritance(t *testing.T) {
s := NewScenario(t, "a pre_start hook without an image must run on the service's built image")
s.Defer(DockerCmd("image", "rm", "-f", s.Project()+"-sample")).
Step("up builds the image and runs the hook on it",
ComposeCmd("up", "-d", "--wait").Within(120*time.Second)).
Step("the service saw the marker only the built image could produce",
ComposeCmd("logs", "sample"),
OutputContains("built-image-marker"))
}
func TestPreStartHookIdempotentReUp(t *testing.T) {
s := NewScenario(t, "an unchanged up must not re-run pre_start hooks on a running service")
s.Step("first up runs the hook once",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("the hook wrote exactly one token",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt")).
Step("an unchanged up does not re-run the hook",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second),
NotRecreated("sample")).
Step("still exactly one token",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt"))
}
func TestPreStartHookReRunOnSpecChange(t *testing.T) {
s := NewScenario(t, "a spec change must recreate the service and re-run its pre_start hooks")
s.Step("up with the v1 spec runs the v1 hook",
ComposeCmd("up", "-d", "--wait").WithEnv("HOOK_VERSION=v1").Within(60*time.Second)).
Step("the volume records v1 only",
probeVolume(s, "cat", "/mnt/versions.txt"),
OutputContains("v1"),
OutputNotContains("v2")).
Step("up with the v2 spec recreates the service and re-runs the hook",
ComposeCmd("up", "-d", "--wait").WithEnv("HOOK_VERSION=v2").Within(60*time.Second),
Recreated("sample")).
Step("the volume records both versions",
probeVolume(s, "cat", "/mnt/versions.txt"),
OutputContains("v1"),
OutputContains("v2"))
}
func TestPreStartHookForceRecreate(t *testing.T) {
s := NewScenario(t, "up --force-recreate must re-run pre_start hooks")
s.Step("first up runs the hook once",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("one token after the first up",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt")).
Step("force-recreate replaces the container and re-runs the hook",
ComposeCmd("up", "-d", "--force-recreate", "--wait").Within(60*time.Second),
Recreated("sample")).
Step("two tokens after the recreate",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("2 /mnt/tokens.txt"))
}
func TestPreStartHookMidSequenceFailure(t *testing.T) {
s := NewScenario(t, "a mid-sequence pre_start failure must run earlier hooks, then fail up pointing at the culprit")
s.Step("up fails on the second hook, naming its index and exit code",
ComposeCmd("up", "-d").MayFail(),
ExitCode(1),
OutputContains("pre_start[1]"),
OutputContains("17"),
ServiceState("sample", "created")).
Step("the first hook did run before the failure",
probeVolume(s, "cat", "/mnt/hooks.txt"),
OutputContains("ran-0"))
}
func TestPreStartHookSequentialOrder(t *testing.T) {
s := NewScenario(t, "pre_start hooks must run sequentially, in declaration order")
s.Step("up runs both hooks",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("the volume shows A before B",
probeVolume(s, "cat", "/mnt/out"),
OutputContains("A\nB"))
}
func TestPreStartHookNotReRunOnScaleUp(t *testing.T) {
s := NewScenario(t, "scaling up must not re-run pre_start hooks while a replica is already running")
s.Step("first up runs the hook once",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("one token after the first up",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt")).
Step("scaling up adds a replica without re-running the hook",
ComposeCmd("up", "-d", "--scale", "sample=2", "--wait").Within(60*time.Second),
ServiceScale("sample", 2)).
Step("still exactly one token",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt"))
}
func TestPreStartHookCreateThenStart(t *testing.T) {
s := NewScenario(t, "create must prepare the pre_start runners so a later start executes them")
s.Step("create leaves the service created, hooks not yet run",
ComposeCmd("create"),
ServiceState("sample", "created")).
Step("nothing in the volume before start",
probeVolume(s, "sh", "-c", "wc -l < /mnt/tokens.txt || echo missing"),
OutputContains("missing")).
Step("start runs the hook then the service",
ComposeCmd("start"),
ServiceState("sample", "running")).
Step("the hook ran exactly once",
probeVolume(s, "wc", "-l", "/mnt/tokens.txt"),
OutputContains("1 /mnt/tokens.txt"))
}
func TestPreStartHookStopThenStartFails(t *testing.T) {
NewScenario(t, "start after stop must fail on a hooked service: the runners were consumed, only a reconciliation (up) prepares new ones").
Step("up runs the hook and starts the service",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("stop leaves the service exited",
ComposeCmd("stop"),
ServiceState("sample", "exited")).
Step("start fails, pointing at the reconciliation command",
ComposeCmd("start").MayFail(),
ExitCode(1),
OutputContains("pre_start[0]"),
OutputContains("docker compose up")).
Step("up prepares fresh runners and recovers",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second),
ServiceState("sample", "running"))
}
func TestPreStartHookRunsOnceForScaledService(t *testing.T) {
s := NewScenario(t, "with the default per_replica: false, a pre_start hook must run once for the whole service")
s.Step("up starts both replicas",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second),
ServiceScale("sample", 2)).
Step("the hook ran exactly once across replicas",
probeVolume(s, "wc", "-l", "/mnt/log"),
OutputContains("1 /mnt/log"))
}
// A pre_start hook is a full container specification inheriting in the
// spirit of the yaml merge rules: collections complete the inherited value,
// the hook's declarations winning on conflicts (docker/compose#13939).
func TestPreStartHookExtraHosts(t *testing.T) {
NewScenario(t, "pre_start hooks must inherit the service's extra_hosts, completed by their own declarations").
Step("up succeeds: both hooks asserted their /etc/hosts content",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second),
ServiceState("app", "running"))
}
// The init-container use case of docker/compose#13934: the hook redeclares a
// service volume read-write to prepare content the service mounts read-only.
func TestPreStartHookVolumes(t *testing.T) {
NewScenario(t, "a pre_start hook must write through its own read-write volume declaration while the service stays read-only").
Step("up succeeds: the hook wrote through its rw mount",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("the service reads what the hook prepared",
ComposeCmd("exec", "app", "cat", "/data/init.txt"),
OutputContains("initialized")).
Step("the service's own mount is still read-only",
ComposeCmd("exec", "app", "touch", "/data/blocked").MayFail(),
OutputContains("Read-only file system"))
}
// Volumes merge by target: the hook keeps every service mount it did not
// redeclare (docker/compose#13934).
func TestPreStartHookVolumesMerge(t *testing.T) {
NewScenario(t, "a pre_start hook declaring a volume must still see the service's other mounts").
Step("up succeeds: the hook read the inherited mount and wrote through its own",
ComposeCmd("up", "-d", "--wait").Within(60*time.Second)).
Step("the hook combined the inherited mount's content with its own write",
ComposeCmd("exec", "app", "cat", "/data/init.txt"),
OutputContains("saw-config"))
}
// A service using volumes_from inherits it into its pre_start hook (full
// container specification): the reference must resolve to the live container
// like the service create path does, not reach the daemon as a raw service
// name.
func TestPreStartHookVolumesFrom(t *testing.T) {
NewScenario(t, "pre_start must work on a service declaring volumes_from").
Step("up succeeds and the hook wrote through the inherited volume",
ComposeCmd("up", "-d", "--wait"),
ServiceState("app", "running"),
ExecOutputContains("data", "cat /shared/marker", "ready"))
}