mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
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]>
308 lines
13 KiB
Go
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"))
|
|
}
|