diff --git a/pkg/compose/containers.go b/pkg/compose/containers.go index 0fb1e7d686..ad6a9c5c07 100644 --- a/pkg/compose/containers.go +++ b/pkg/compose/containers.go @@ -162,6 +162,10 @@ func isNotOneOff(c container.Summary) bool { return !ok || v == "False" } +func isNotRunning(c container.Summary) bool { + return c.State != container.StateRunning +} + // filter return Containers with elements to match predicate func (containers Containers) filter(predicates ...containerPredicate) Containers { var filtered Containers diff --git a/pkg/compose/convergence.go b/pkg/compose/convergence.go index 8e7803f9d1..67917f20db 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -537,37 +537,52 @@ func (s *composeService) startService(ctx context.Context, return fmt.Errorf("service %q has no container to start", service.Name) } - for _, ctr := range containers.filter(isService(service.Name)) { - if ctr.State == container.StateRunning { - continue - } + serviceContainers := containers.filter(isService(service.Name), isNotOneOff) + toStart := serviceContainers.filter(isNotRunning) + if len(toStart) == 0 { + return nil + } - err = s.injectSecrets(ctx, project, service, ctr.ID) - if err != nil { + // pre_start runs once per service, only when no replica is already running + // (e.g. initial up, force-recreate, or spec change). per_replica: false is + // the only currently supported mode. Pick the replica with the lowest + // container-number so the choice is deterministic regardless of the order + // the daemon returns containers in. + if len(service.PreStart) > 0 && len(serviceContainers) == len(toStart) { + if err := s.runPreStart(ctx, project, service, lowestNumberedContainer(toStart), listener); err != nil { return err } + } - err = s.injectConfigs(ctx, project, service, ctr.ID) - if err != nil { + for _, ctr := range toStart { + if err := s.startServiceContainer(ctx, project, service, ctr, listener); err != nil { return err } + } + return nil +} - eventName := getContainerProgressName(ctr) - s.events.On(newEvent(eventName, api.Working, api.StatusStarting)) - _, err = s.apiClient().ContainerStart(ctx, ctr.ID, client.ContainerStartOptions{}) - if err != nil { - return err - } +func (s *composeService) startServiceContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, ctr container.Summary, listener api.ContainerEventListener) error { + if err := s.injectSecrets(ctx, project, service, ctr.ID); err != nil { + return err + } + if err := s.injectConfigs(ctx, project, service, ctr.ID); err != nil { + return err + } - for _, hook := range service.PostStart { - err = s.runHook(ctx, ctr, service, hook, listener) - if err != nil { - return err - } - } + eventName := getContainerProgressName(ctr) + s.events.On(newEvent(eventName, api.Working, api.StatusStarting)) + if _, err := s.apiClient().ContainerStart(ctx, ctr.ID, client.ContainerStartOptions{}); err != nil { + return err + } - s.events.On(newEvent(eventName, api.Done, api.StatusStarted)) + for _, hook := range service.PostStart { + if err := s.runHook(ctx, ctr, service, hook, listener); err != nil { + return err + } } + + s.events.On(newEvent(eventName, api.Done, api.StatusStarted)) return nil } diff --git a/pkg/compose/pre_start.go b/pkg/compose/pre_start.go new file mode 100644 index 0000000000..439969e16e --- /dev/null +++ b/pkg/compose/pre_start.go @@ -0,0 +1,301 @@ +/* + Copyright 2020 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 compose + +import ( + "context" + "fmt" + "strconv" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "github.com/moby/moby/client/pkg/versions" + "github.com/sirupsen/logrus" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/utils" +) + +// lowestNumberedContainer returns the container with the lowest +// com.docker.compose.container-number label, so pre_start always targets the +// same replica regardless of the order the daemon returned them in. +// Panics on an empty slice; callers must guard. +func lowestNumberedContainer(containers Containers) container.Summary { + pick := containers[0] + pickNum, _ := strconv.Atoi(pick.Labels[api.ContainerNumberLabel]) + for _, ctr := range containers[1:] { + num, _ := strconv.Atoi(ctr.Labels[api.ContainerNumberLabel]) + if num < pickNum { + pick, pickNum = ctr, num + } + } + return pick +} + +// runPreStart executes the service's pre_start hooks sequentially, in declared +// order. Each hook runs as an ephemeral container that shares the service +// container's volumes via VolumesFrom and is attached to the same networks. +// A non-zero exit gates service start. +// +// With per_replica: false (the only currently supported mode), the hook sees +// the volumes of the first non-running replica only — anonymous volumes and +// tmpfs mounts are per-replica and not shared. Use named volumes or bind +// mounts for data the hook produces. +func (s *composeService) runPreStart(ctx context.Context, project *types.Project, service types.ServiceConfig, ctr container.Summary, listener api.ContainerEventListener) error { + // Validate every hook up front so an unsupported entry never triggers any I/O. + for i, hook := range service.PreStart { + if hook.PerReplica { + return fmt.Errorf("service %q pre_start[%d]: per_replica is not yet supported; remove per_replica or set it to false", service.Name, i) + } + } + for i, hook := range service.PreStart { + if err := s.runPreStartHook(ctx, project, service, ctr, i, hook, listener); err != nil { + return err + } + } + return nil +} + +func (s *composeService) runPreStartHook( + ctx context.Context, project *types.Project, service types.ServiceConfig, + ctr container.Summary, index int, hook types.ServiceHook, listener api.ContainerEventListener, +) error { + created, err := s.createPreStartContainer(ctx, project, service, ctr, hook) + if err != nil { + return err + } + + // Subscribe to wait before start to avoid missing the exit event for short-lived hooks. + // WaitConditionNotRunning would match immediately because the container is still in + // "created" state, so use WaitConditionNextExit to block until the run actually finishes. + waitRes := s.apiClient().ContainerWait(ctx, created.ID, client.ContainerWaitOptions{ + Condition: container.WaitConditionNextExit, + }) + + // Open the log stream before ContainerStart so AutoRemove cannot race us + // to a 404 on a fast-exiting hook. The dedicated logCtx lets us force the + // follow stream closed once the hook has exited, so a daemon that keeps + // the connection open cannot deadlock `<-logsDone`. + logCtx, cancelLogs := context.WithCancel(ctx) + defer cancelLogs() + logsDone := s.streamPreStartLogs(logCtx, created.ID, service, index, listener) + + if _, err := s.apiClient().ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { + // AutoRemove only fires after a successful start, so the never-started + // container has to be dropped explicitly. A failed removal is logged + // at warn level — without that hint the orphan is only discoverable + // via the project/service labels. + if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true}); removeErr != nil { + logrus.Warnf("service %q pre_start[%d]: failed to remove orphan hook container %s: %v", service.Name, index, created.ID, removeErr) + } + // Drain waitRes so the client's wait goroutine exits without having to + // wait for the parent context to be canceled. + select { + case <-waitRes.Error: + case <-waitRes.Result: + case <-ctx.Done(): + } + cancelLogs() + <-logsDone + return err + } + + waitErr := waitPreStart(ctx, service.Name, index, waitRes) + cancelLogs() + <-logsDone + return waitErr +} + +func (s *composeService) createPreStartContainer( + ctx context.Context, project *types.Project, service types.ServiceConfig, + ctr container.Summary, hook types.ServiceHook, +) (client.ContainerCreateResult, error) { + image := hook.Image + if image == "" { + image = api.GetImageNameOrDefault(service, project.Name) + } + + cfg := &container.Config{ + Image: image, + Cmd: hook.Command, + User: hook.User, + WorkingDir: hook.WorkingDir, + Env: append(ToMobyEnv(service.Environment), ToMobyEnv(hook.Environment)...), + // Tag the ephemeral hook container with the project/service it belongs + // to so a failed AutoRemove leaves something that `compose down` (and + // other label-scoped tooling) can still find. + Labels: map[string]string{ + api.ProjectLabel: project.Name, + api.ServiceLabel: service.Name, + api.VersionLabel: api.ComposeVersion, + }, + } + hostCfg := &container.HostConfig{ + AutoRemove: true, + Privileged: hook.Privileged, + VolumesFrom: []string{ctr.ID}, + } + + apiVersion, err := s.RuntimeAPIVersion(ctx) + if err != nil { + return client.ContainerCreateResult{}, err + } + + networkMode, networkingConfig, err := defaultNetworkSettings(project, service, 0, nil, true, apiVersion) + if err != nil { + return client.ContainerCreateResult{}, err + } + hostCfg.NetworkMode = networkMode + + created, err := s.apiClient().ContainerCreate(ctx, client.ContainerCreateOptions{ + Config: cfg, + HostConfig: hostCfg, + NetworkingConfig: networkingConfig, + }) + if err != nil { + return client.ContainerCreateResult{}, err + } + + if versions.LessThan(apiVersion, apiVersion144) { + if err := s.connectPreStartExtraNetworks(ctx, project, service, created.ID, networkMode); err != nil { + // Same reason as the ContainerStart-failure cleanup: AutoRemove never + // fires on a container that was created but not started. Surface + // any cleanup failure so the orphan is at least visible in logs. + if _, removeErr := s.apiClient().ContainerRemove(ctx, created.ID, client.ContainerRemoveOptions{Force: true}); removeErr != nil { + logrus.Warnf("service %q pre_start: failed to remove orphan hook container %s: %v", service.Name, created.ID, removeErr) + } + return client.ContainerCreateResult{}, err + } + } + return created, nil +} + +// connectPreStartExtraNetworks mirrors the createMobyContainer fallback path for +// older API versions: ContainerCreate only accepts one EndpointsConfig, so extra +// networks have to be attached via NetworkConnect after creation. +func (s *composeService) connectPreStartExtraNetworks(ctx context.Context, project *types.Project, service types.ServiceConfig, containerID string, primary container.NetworkMode) error { + for _, networkKey := range service.NetworksByPriority() { + mobyNetworkName := project.Networks[networkKey].Name + if string(primary) == mobyNetworkName { + continue + } + eps, err := createEndpointSettings(project, service, 0, networkKey, nil, true) + if err != nil { + return err + } + if _, err := s.apiClient().NetworkConnect(ctx, mobyNetworkName, client.NetworkConnectOptions{ + Container: containerID, + EndpointConfig: eps, + }); err != nil { + return err + } + } + return nil +} + +func waitPreStart(ctx context.Context, serviceName string, index int, waitRes client.ContainerWaitResult) error { + // ContainerWait can deliver on Result and Error at the same instant. Two + // races have to be closed deterministically here: + // 1. The daemon closing a successful stream cleanly sends nil on Error + // AND the exit code on Result — a plain 3-case select would let Go + // pick the Error branch and report a spurious "wait ended" failure. + // 2. A real transport error on Error can race with a stale Result — if + // the scheduler picks Result, we would silently drop the error and + // let the service start. + // Loop until Result is delivered, nil-ing the Error channel after a nil + // receive so a closed channel cannot busy-loop. After Result lands, do a + // non-blocking check on Error so a real error still wins over Result. + errCh := waitRes.Error + for { + select { + case <-ctx.Done(): + return ctx.Err() + case res := <-waitRes.Result: + select { + case err := <-errCh: + if err != nil { + return err + } + default: + } + return preStartResultErr(serviceName, index, res) + case err := <-errCh: + if err != nil { + return err + } + // nil on Error: stream closed cleanly. Disable this case so a + // closed channel can't fire repeatedly. + errCh = nil + } + } +} + +func preStartResultErr(serviceName string, index int, res container.WaitResponse) error { + if res.Error != nil { + return fmt.Errorf("service %q pre_start[%d] wait error: %s", serviceName, index, res.Error.Message) + } + if res.StatusCode != 0 { + return fmt.Errorf("service %q pre_start[%d] exited with code %d", serviceName, index, res.StatusCode) + } + return nil +} + +// streamPreStartLogs returns a channel that is closed once the hook log stream +// has been fully drained (or never opened). Callers must wait on it before +// returning so the goroutine cannot outlive the hook. +func (s *composeService) streamPreStartLogs(ctx context.Context, containerID string, service types.ServiceConfig, index int, listener api.ContainerEventListener) <-chan struct{} { + done := make(chan struct{}) + if listener == nil { + close(done) + return done + } + source := fmt.Sprintf("%s pre_start[%d] ->", service.Name, index) + logs, err := s.apiClient().ContainerLogs(ctx, containerID, client.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + }) + if err != nil { + listener(api.ContainerEvent{ + Type: api.HookEventLog, + Source: source, + ID: containerID, + Service: service.Name, + Line: fmt.Sprintf("warning: could not attach pre_start log stream: %s", err), + }) + close(done) + return done + } + go func() { + defer close(done) + defer logs.Close() //nolint:errcheck + w := utils.GetWriter(func(line string) { + listener(api.ContainerEvent{ + Type: api.HookEventLog, + Source: source, + ID: containerID, + Service: service.Name, + Line: line, + }) + }) + defer w.Close() //nolint:errcheck + _, _ = stdcopy.StdCopy(w, w, logs) + }() + return done +} diff --git a/pkg/compose/pre_start_test.go b/pkg/compose/pre_start_test.go new file mode 100644 index 0000000000..005bc5a012 --- /dev/null +++ b/pkg/compose/pre_start_test.go @@ -0,0 +1,369 @@ +/* + Copyright 2020 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 compose + +import ( + "bytes" + "fmt" + "io" + "testing" + + "github.com/compose-spec/compose-go/v2/types" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/client" + "go.uber.org/goleak" + "go.uber.org/mock/gomock" + "gotest.tools/v3/assert" + + "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/mocks" +) + +func newPreStartTestService(t *testing.T) (*composeService, *mocks.MockAPIClient) { + t.Helper() + // Register the goroutine-leak check first so it runs last (t.Cleanup is + // LIFO), after gomock's own cleanup has drained any internal goroutines. + // Any goroutine spawned by the code under test that outlives the test will + // fail this assertion. + ignoreExisting := goleak.IgnoreCurrent() + t.Cleanup(func() { + goleak.VerifyNone(t, ignoreExisting) + }) + mockCtrl := gomock.NewController(t) + t.Cleanup(mockCtrl.Finish) + apiClient := mocks.NewMockAPIClient(mockCtrl) + cli := mocks.NewMockCli(mockCtrl) + cli.EXPECT().Client().Return(apiClient).AnyTimes() + apiClient.EXPECT().Ping(gomock.Any(), client.PingOptions{NegotiateAPIVersion: true}). + Return(client.PingResult{APIVersion: "1.44"}, nil).AnyTimes() + apiClient.EXPECT().ClientVersion().Return("1.44").AnyTimes() + tested, err := NewComposeService(cli) + assert.NilError(t, err) + return tested.(*composeService), apiClient +} + +func waitResultExit(code int64) client.ContainerWaitResult { + resultC := make(chan container.WaitResponse, 1) + errC := make(chan error, 1) + resultC <- container.WaitResponse{StatusCode: code} + return client.ContainerWaitResult{Result: resultC, Error: errC} +} + +func emptyLogs() client.ContainerLogsResult { + return io.NopCloser(bytes.NewReader(nil)) +} + +func TestPreStart_SuccessTwoHooksInOrder(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"echo", "first"}}, + {Image: "alpine", Command: types.ShellCommand{"echo", "second"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + // Hook 1: create → wait (subscribe) → logs (subscribe) → start. + create1 := apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{ID: "hook-1"}, nil) + wait1 := apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)).After(create1) + logs1 := apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil).After(wait1) + start1 := apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(logs1) + + // Hook 2 is only created after hook 1 has been started (and waited on). + create2 := apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{ID: "hook-2"}, nil).After(start1) + wait2 := apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-2", gomock.Any()). + Return(waitResultExit(0)).After(create2) + logs2 := apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-2", gomock.Any()). + Return(emptyLogs(), nil).After(wait2) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-2", gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(logs2) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) +} + +func TestPreStart_FirstHookFailsStopsExecution(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"false"}}, + {Image: "alpine", Command: types.ShellCommand{"echo", "never"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + create1 := apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{ID: "hook-1"}, nil) + wait1 := apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(42)).After(create1) + logs1 := apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil).After(wait1) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil).After(logs1) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.ErrorContains(t, err, `service "web" pre_start[0]`) + assert.ErrorContains(t, err, "42") +} + +func TestPreStart_PerReplicaRejected(t *testing.T) { + tested, _ := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"true"}, PerReplica: true}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.ErrorContains(t, err, `service "web" pre_start[0]`) + assert.ErrorContains(t, err, "per_replica is not yet supported") +} + +func TestPreStart_ImageFallsBackToBuiltImage(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + // Service with no explicit image (build-only); hook image also empty. + service := types.ServiceConfig{ + Name: "web", + PreStart: []types.ServiceHook{ + {Command: types.ShellCommand{"echo", "hi"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + var gotImage string + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + gotImage = opts.Config.Image + return client.ContainerCreateResult{ID: "hook-1"}, nil + }) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) + assert.Equal(t, gotImage, api.GetImageNameOrDefault(service, project.Name)) +} + +func TestPreStart_ExplicitHookImageUsed(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "service-image:latest", + PreStart: []types.ServiceHook{ + {Image: "custom-hook-image:1.2.3", Command: types.ShellCommand{"echo"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + var gotImage string + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + gotImage = opts.Config.Image + return client.ContainerCreateResult{ID: "hook-1"}, nil + }) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) + assert.Equal(t, gotImage, "custom-hook-image:1.2.3") +} + +func TestPreStart_VolumesFromServiceContainer(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"true"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + var gotVolumesFrom []string + var gotAutoRemove bool + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ any, opts client.ContainerCreateOptions) (client.ContainerCreateResult, error) { + gotVolumesFrom = opts.HostConfig.VolumesFrom + gotAutoRemove = opts.HostConfig.AutoRemove + return client.ContainerCreateResult{ID: "hook-1"}, nil + }) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) + assert.DeepEqual(t, gotVolumesFrom, []string{"service-ctr-id"}) + assert.Assert(t, gotAutoRemove) +} + +func TestPreStart_ContainerCreateFailurePropagates(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "missing:latest", Command: types.ShellCommand{"true"}}, + {Image: "alpine", Command: types.ShellCommand{"never"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{}, fmt.Errorf("no such image: missing:latest")) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.ErrorContains(t, err, "no such image") +} + +func TestPreStart_ContainerStartFailurePropagates(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"true"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + create1 := apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{ID: "hook-1"}, nil) + wait1 := apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(waitResultExit(0)).After(create1) + logs1 := apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil).After(wait1) + start1 := apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, fmt.Errorf("daemon: container start failed")).After(logs1) + // AutoRemove never fires when start fails, so the hook must drop the ghost + // container explicitly. + apiClient.EXPECT().ContainerRemove(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerRemoveResult{}, nil).After(start1) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.ErrorContains(t, err, "container start failed") +} + +// TestPreStart_WaitResultPreferredOverNilError pins the fix for the scheduler +// race in waitPreStart: when ContainerWait closes a successful stream cleanly +// it delivers Result (exit code) AND a nil send on Error at the same time. +// A naive 3-case select would pick Error half the time and turn the run into +// a spurious "wait ended without an exit status" failure. The function must +// always settle on the Result-based outcome. +func TestPreStart_WaitResultPreferredOverNilError(t *testing.T) { + tested, apiClient := newPreStartTestService(t) + + project := &types.Project{Name: "demo"} + service := types.ServiceConfig{ + Name: "web", + Image: "alpine", + PreStart: []types.ServiceHook{ + {Image: "alpine", Command: types.ShellCommand{"true"}}, + }, + } + ctr := container.Summary{ID: "service-ctr-id"} + + // Both channels are buffered and pre-populated so the outer select in + // waitPreStart sees them ready at the same instant. + resultC := make(chan container.WaitResponse, 1) + errC := make(chan error, 1) + resultC <- container.WaitResponse{StatusCode: 0} + errC <- nil + + apiClient.EXPECT().ContainerCreate(gomock.Any(), gomock.Any()). + Return(client.ContainerCreateResult{ID: "hook-1"}, nil) + apiClient.EXPECT().ContainerWait(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerWaitResult{Result: resultC, Error: errC}) + apiClient.EXPECT().ContainerLogs(gomock.Any(), "hook-1", gomock.Any()). + Return(emptyLogs(), nil) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, nil) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.NilError(t, err) +} + +// TestWaitPreStart_RaceNilErrorAndResult stress-tests the scheduler outcome +// when ContainerWait closes a successful stream cleanly: Result has the exit +// code and Error sends nil at the same instant. Either branch of the outer +// select must end on the Result-based success, with no spurious failure. +func TestWaitPreStart_RaceNilErrorAndResult(t *testing.T) { + for i := 0; i < 100; i++ { + resultC := make(chan container.WaitResponse, 1) + errC := make(chan error, 1) + resultC <- container.WaitResponse{StatusCode: 0} + errC <- nil + waitRes := client.ContainerWaitResult{Result: resultC, Error: errC} + assert.NilError(t, waitPreStart(t.Context(), "web", 0, waitRes)) + } +} + +// TestWaitPreStart_RaceRealErrorAndResult stress-tests the opposite scenario: +// a real transport error on Error races with a stale Result. The Error must +// always win — the function must never silently drop the failure and return +// success based on Result. +func TestWaitPreStart_RaceRealErrorAndResult(t *testing.T) { + for i := 0; i < 100; i++ { + resultC := make(chan container.WaitResponse, 1) + errC := make(chan error, 1) + resultC <- container.WaitResponse{StatusCode: 0} + errC <- fmt.Errorf("daemon: connection lost") + waitRes := client.ContainerWaitResult{Result: resultC, Error: errC} + err := waitPreStart(t.Context(), "web", 0, waitRes) + assert.ErrorContains(t, err, "connection lost") + } +} diff --git a/pkg/e2e/fixtures/pre_start/Dockerfile b/pkg/e2e/fixtures/pre_start/Dockerfile new file mode 100644 index 0000000000..a04cacd2ab --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/Dockerfile @@ -0,0 +1,17 @@ +# Copyright 2020 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. + +FROM alpine +RUN printf '#!/bin/sh\necho "built-image-marker"\n' > /usr/local/bin/built-marker \ + && chmod +x /usr/local/bin/built-marker diff --git a/pkg/e2e/fixtures/pre_start/compose-build.yaml b/pkg/e2e/fixtures/pre_start/compose-build.yaml new file mode 100644 index 0000000000..a47ecf0d88 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/compose-build.yaml @@ -0,0 +1,12 @@ +services: + sample: + build: + context: . + command: sh -c 'cat /shared/marker.txt && sleep 5' + volumes: + - data:/shared + pre_start: + # No image specified - must fall back to the service's built image. + - command: sh -c 'built-marker > /shared/marker.txt' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/compose-error.yaml b/pkg/e2e/fixtures/pre_start/compose-error.yaml new file mode 100644 index 0000000000..7062611946 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/compose-error.yaml @@ -0,0 +1,7 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + pre_start: + - image: alpine + command: sh -c 'exit 17' diff --git a/pkg/e2e/fixtures/pre_start/compose-success.yaml b/pkg/e2e/fixtures/pre_start/compose-success.yaml new file mode 100644 index 0000000000..fb448ca25d --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/compose-success.yaml @@ -0,0 +1,11 @@ +services: + sample: + image: alpine + command: sh -c 'cat /shared/init.txt && sleep 5' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo "initialized" > /shared/init.txt' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/idempotent/compose.yaml b/pkg/e2e/fixtures/pre_start/idempotent/compose.yaml new file mode 100644 index 0000000000..e00ab31f58 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/idempotent/compose.yaml @@ -0,0 +1,11 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo $(cat /proc/sys/kernel/random/uuid) >> /shared/tokens.txt' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/mid-failure/compose.yaml b/pkg/e2e/fixtures/pre_start/mid-failure/compose.yaml new file mode 100644 index 0000000000..2019b178fd --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/mid-failure/compose.yaml @@ -0,0 +1,13 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo ran-0 >> /shared/hooks.txt' + - image: alpine + command: sh -c 'exit 17' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/scaled/compose.yaml b/pkg/e2e/fixtures/pre_start/scaled/compose.yaml new file mode 100644 index 0000000000..4396b0a209 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/scaled/compose.yaml @@ -0,0 +1,13 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + deploy: + replicas: 2 + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo "ran" >> /shared/log' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/sequential/compose.yaml b/pkg/e2e/fixtures/pre_start/sequential/compose.yaml new file mode 100644 index 0000000000..7c73311978 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/sequential/compose.yaml @@ -0,0 +1,13 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo A >> /shared/out' + - image: alpine + command: sh -c 'echo B >> /shared/out' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/spec-change/compose.v1.yaml b/pkg/e2e/fixtures/pre_start/spec-change/compose.v1.yaml new file mode 100644 index 0000000000..2bfa90679d --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/spec-change/compose.v1.yaml @@ -0,0 +1,11 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo v1 >> /shared/versions.txt' +volumes: + data: diff --git a/pkg/e2e/fixtures/pre_start/spec-change/compose.v2.yaml b/pkg/e2e/fixtures/pre_start/spec-change/compose.v2.yaml new file mode 100644 index 0000000000..11347aaf5e --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/spec-change/compose.v2.yaml @@ -0,0 +1,11 @@ +services: + sample: + image: alpine + command: sh -c 'sleep 30' + volumes: + - data:/shared + pre_start: + - image: alpine + command: sh -c 'echo v2 >> /shared/versions.txt' +volumes: + data: diff --git a/pkg/e2e/hooks_test.go b/pkg/e2e/hooks_test.go index b77500c6bf..7623c6da64 100644 --- a/pkg/e2e/hooks_test.go +++ b/pkg/e2e/hooks_test.go @@ -16,6 +16,7 @@ limitations under the License. package e2e import ( + "strconv" "strings" "testing" @@ -23,6 +24,17 @@ import ( "gotest.tools/v3/icmd" ) +// wcLineCount parses the leading integer from a `wc -l ` stdout, whose +// shape is " ". Fails the test if the output cannot be parsed. +func wcLineCount(t *testing.T, stdout string) int { + t.Helper() + fields := strings.Fields(stdout) + assert.Assert(t, len(fields) > 0, "expected wc -l output, got: %q", stdout) + n, err := strconv.Atoi(fields[0]) + assert.NilError(t, err, "expected leading integer in wc -l output, got: %q", stdout) + return n +} + func TestPostStartHookInError(t *testing.T) { c := NewParallelCLI(t) const projectName = "hooks-post-start-failure" @@ -107,3 +119,223 @@ func TestPostStartAndPreStopHook(t *testing.T) { res := c.RunDockerComposeCmd(t, "-f", "fixtures/hooks/compose.yaml", "--project-name", projectName, "up", "-d") res.Assert(t, icmd.Expected{ExitCode: 0}) } + +func TestPreStartHookSuccess(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "hooks-pre-start-success" + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-success.yaml", "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + res := c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-success.yaml", "--project-name", projectName, "up", "-d", "--wait") + res.Assert(t, icmd.Expected{ExitCode: 0}) + + // Service should be able to read the file written by the pre_start hook. + logs := c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-success.yaml", "--project-name", projectName, "logs", "sample") + assert.Assert(t, strings.Contains(logs.Combined(), "initialized"), logs.Combined()) +} + +func TestPreStartHookInError(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "hooks-pre-start-failure" + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-error.yaml", "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + res := c.RunDockerComposeCmdNoCheck(t, "-f", "fixtures/pre_start/compose-error.yaml", "--project-name", projectName, "up", "-d") + res.Assert(t, icmd.Expected{ExitCode: 1}) + assert.Assert(t, strings.Contains(res.Combined(), "pre_start"), res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), "17"), res.Combined()) + + // The service container should exist but not be running. + ps := c.RunDockerCmd(t, "ps", "-a", "--filter", "label=com.docker.compose.project="+projectName, "--format", "{{.Names}} {{.State}}") + assert.Assert(t, strings.Contains(ps.Combined(), "sample"), ps.Combined()) + assert.Assert(t, !strings.Contains(ps.Combined(), "running"), ps.Combined()) +} + +func TestPreStartHookBuildInheritance(t *testing.T) { + c := NewParallelCLI(t) + const projectName = "hooks-pre-start-build" + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-build.yaml", "--project-name", projectName, "down", "-v", "--remove-orphans", "--rmi", "local", "-t", "0") + }) + + res := c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-build.yaml", "--project-name", projectName, "up", "-d", "--wait") + res.Assert(t, icmd.Expected{ExitCode: 0}) + + logs := c.RunDockerComposeCmd(t, "-f", "fixtures/pre_start/compose-build.yaml", "--project-name", projectName, "logs", "sample") + assert.Assert(t, strings.Contains(logs.Combined(), "built-image-marker"), logs.Combined()) +} + +func TestPreStartHookIdempotentReUp(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-idempotent" + composeFile = "fixtures/pre_start/idempotent/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + // First up: hook writes one unique token. + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + // Probe: exactly 1 line in the tokens file. + probe := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe.Stdout()), 1, "expected 1 token line after first up, got: %s", probe.Stdout()) + + // Second up with no spec change: service is already running so the hook must NOT re-run. + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + // Probe again: still exactly 1 line. + probe2 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe2.Stdout()), 1, "expected 1 token line after idempotent re-up, got: %s", probe2.Stdout()) +} + +func TestPreStartHookReRunOnSpecChange(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-spec-change" + composeV1 = "fixtures/pre_start/spec-change/compose.v1.yaml" + composeV2 = "fixtures/pre_start/spec-change/compose.v2.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeV2, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + // First up with v1 spec: hook appends "v1". + c.RunDockerComposeCmd(t, "-f", composeV1, "--project-name", projectName, "up", "-d", "--wait") + + // Probe: file contains "v1". + probe1 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "cat", "/mnt/versions.txt") + assert.Assert(t, strings.Contains(probe1.Stdout(), "v1"), "expected v1 after first up, got: %s", probe1.Stdout()) + assert.Assert(t, !strings.Contains(probe1.Stdout(), "v2"), "did not expect v2 yet, got: %s", probe1.Stdout()) + + // Second up with v2 spec: hook command changed, container recreated, hook runs again and appends "v2". + c.RunDockerComposeCmd(t, "-f", composeV2, "--project-name", projectName, "up", "-d", "--wait") + + // Probe: file contains both v1 and v2. + probe2 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "cat", "/mnt/versions.txt") + assert.Assert(t, strings.Contains(probe2.Stdout(), "v1"), "expected v1 still present, got: %s", probe2.Stdout()) + assert.Assert(t, strings.Contains(probe2.Stdout(), "v2"), "expected v2 appended after spec change, got: %s", probe2.Stdout()) +} + +func TestPreStartHookForceRecreate(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-force-recreate" + composeFile = "fixtures/pre_start/idempotent/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + // First up: hook writes one unique token. + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + probe1 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe1.Stdout()), 1, "expected 1 token line after first up, got: %s", probe1.Stdout()) + + // Force-recreate: container is rebuilt so the hook must run again. + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--force-recreate", "--wait") + + // Probe: now 2 lines (one from each up). + probe2 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe2.Stdout()), 2, "expected 2 token lines after --force-recreate, got: %s", probe2.Stdout()) +} + +func TestPreStartHookMidSequenceFailure(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-mid-failure" + composeFile = "fixtures/pre_start/mid-failure/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + // Hook 0 succeeds; hook 1 exits with code 17. up must fail. + res := c.RunDockerComposeCmdNoCheck(t, "-f", composeFile, "--project-name", projectName, "up", "-d") + res.Assert(t, icmd.Expected{ExitCode: 1}) + + // Error must point at hook index 1 (not 0) and report exit code 17. + assert.Assert(t, strings.Contains(res.Combined(), "pre_start[1]"), "expected pre_start[1] in output, got: %s", res.Combined()) + assert.Assert(t, strings.Contains(res.Combined(), "17"), "expected exit code 17 in output, got: %s", res.Combined()) + + // Hook 0 must have run before hook 1 failed: the file must contain "ran-0". + probe := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "cat", "/mnt/hooks.txt") + assert.Assert(t, strings.Contains(probe.Stdout(), "ran-0"), "expected hook 0 output in volume, got: %s", probe.Stdout()) + + // The service container must exist but not be running. + ps := c.RunDockerCmd(t, "ps", "-a", "--filter", "label=com.docker.compose.project="+projectName, "--format", "{{.Names}} {{.State}}") + assert.Assert(t, strings.Contains(ps.Combined(), "sample"), "expected service container in ps output, got: %s", ps.Combined()) + assert.Assert(t, !strings.Contains(ps.Combined(), "running"), "service container must not be running, got: %s", ps.Combined()) +} + +func TestPreStartHookSequentialOrder(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-sequential" + composeFile = "fixtures/pre_start/sequential/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + // File must contain A then B in that exact order. + probe := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "cat", "/mnt/out") + assert.Equal(t, probe.Stdout(), "A\nB\n", "expected hooks to run in order A then B") +} + +func TestPreStartHookNotReRunOnScaleUp(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-scale-up" + composeFile = "fixtures/pre_start/idempotent/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + probe1 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe1.Stdout()), 1, "expected 1 token after first up, got: %s", probe1.Stdout()) + + // Scale up: the new replica must NOT re-run pre_start because another replica is already running. + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--scale", "sample=2", "--wait") + + probe2 := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/tokens.txt") + assert.Equal(t, wcLineCount(t, probe2.Stdout()), 1, "expected still 1 token after scale-up, got: %s", probe2.Stdout()) +} + +func TestPreStartHookRunsOnceForScaledService(t *testing.T) { + c := NewParallelCLI(t) + const ( + projectName = "hooks-pre-start-scaled" + composeFile = "fixtures/pre_start/scaled/compose.yaml" + ) + + t.Cleanup(func() { + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "down", "-v", "--remove-orphans", "-t", "0") + }) + + c.RunDockerComposeCmd(t, "-f", composeFile, "--project-name", projectName, "up", "-d", "--wait") + + // per_replica: false (default) → hook must run ONCE for the whole service, + // even with deploy.replicas: 2. + probe := c.RunDockerCmd(t, "run", "--rm", "-v", projectName+"_data:/mnt", "alpine", "wc", "-l", "/mnt/log") + assert.Assert(t, strings.HasPrefix(strings.TrimSpace(probe.Stdout()), "1 "), + "expected hook to run exactly once across replicas, got: %q", probe.Stdout()) +}