diff --git a/go.mod b/go.mod index 2b467ec006..4c49191b56 100644 --- a/go.mod +++ b/go.mod @@ -152,3 +152,5 @@ exclude ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ) + +replace github.com/compose-spec/compose-go/v2 => github.com/glours/compose-go/v2 v2.0.0-20260616130530-4d8d8518aa44 diff --git a/go.sum b/go.sum index eeb59f2a66..00cad3dc6f 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,6 @@ github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= -github.com/compose-spec/compose-go/v2 v2.11.0 h1:xoq/ootgIL6TsHmbJHrkuh7+bzjhPV3NHftHRPPyVXM= -github.com/compose-spec/compose-go/v2 v2.11.0/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/containerd/cgroups/v3 v3.1.3 h1:eUNflyMddm18+yrDmZPn3jI7C5hJ9ahABE5q6dyLYXQ= github.com/containerd/cgroups/v3 v3.1.3/go.mod h1:PKZ2AcWmSBsY/tJUVhtS/rluX0b1uq1GmPO1ElCmbOw= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= @@ -118,6 +116,8 @@ github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w= github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw= github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= +github.com/glours/compose-go/v2 v2.0.0-20260616130530-4d8d8518aa44 h1:XDtwWZnY8HX5rxRvC1g2oAuZibFDm45iBLCpp4KzQ7k= +github.com/glours/compose-go/v2 v2.0.0-20260616130530-4d8d8518aa44/go.mod h1:ZU6zlcweCZKyiB7BVfCizQT9XmkEIMFE+PRZydVcsZg= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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..8e7f715808 100644 --- a/pkg/compose/convergence.go +++ b/pkg/compose/convergence.go @@ -537,37 +537,50 @@ 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. + if len(service.PreStart) > 0 && len(serviceContainers) == len(toStart) { + if err := s.runPreStart(ctx, project, service, toStart[0], 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..a6e8de228e --- /dev/null +++ b/pkg/compose/pre_start.go @@ -0,0 +1,219 @@ +/* + 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" + + "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/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/utils" +) + +// 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. + logsDone := s.streamPreStartLogs(ctx, created.ID, service, index, listener) + + if _, err := s.apiClient().ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { + <-logsDone + return err + } + + waitErr := waitPreStart(ctx, service.Name, index, waitRes) + <-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)...), + } + 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 { + 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 { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-waitRes.Error: + return err + case res := <-waitRes.Result: + 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..e883ff8bcd --- /dev/null +++ b/pkg/compose/pre_start_test.go @@ -0,0 +1,286 @@ +/* + 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/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() + 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) + apiClient.EXPECT().ContainerStart(gomock.Any(), "hook-1", gomock.Any()). + Return(client.ContainerStartResult{}, fmt.Errorf("daemon: container start failed")).After(logs1) + + err := tested.runPreStart(t.Context(), project, service, ctr, func(api.ContainerEvent) {}) + assert.ErrorContains(t, err, "container start failed") +} 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..e1d39ec99c --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/compose-build.yaml @@ -0,0 +1,13 @@ +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: + name: pre-start-build-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..88c436f4b1 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/compose-success.yaml @@ -0,0 +1,12 @@ +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: + name: pre-start-success-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..5fc9b52306 --- /dev/null +++ b/pkg/e2e/fixtures/pre_start/scaled/compose.yaml @@ -0,0 +1,14 @@ +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: + name: pre-start-scaled-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..9281201893 100644 --- a/pkg/e2e/hooks_test.go +++ b/pkg/e2e/hooks_test.go @@ -107,3 +107,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.Assert(t, strings.Contains(strings.TrimSpace(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.Assert(t, strings.Contains(strings.TrimSpace(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.Assert(t, strings.Contains(strings.TrimSpace(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.Assert(t, strings.Contains(strings.TrimSpace(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.Assert(t, strings.Contains(strings.TrimSpace(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.Assert(t, strings.Contains(strings.TrimSpace(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", "pre-start-scaled-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()) +}