mirror of
https://github.com/docker/compose.git
synced 2026-08-27 03:45:29 +00:00
pre_start: support pre_start lifecycle hooks
Some checks failed
ci / validate (lint) (push) Has been cancelled
ci / validate (validate-docs) (push) Has been cancelled
ci / validate (validate-go-mod) (push) Has been cancelled
ci / validate (validate-headers) (push) Has been cancelled
ci / binary (push) Has been cancelled
ci / bin-image-test (push) Has been cancelled
ci / test (push) Has been cancelled
ci / e2e (plugin, oldstable) (push) Has been cancelled
ci / e2e (standalone, oldstable) (push) Has been cancelled
ci / e2e (plugin, stable) (push) Has been cancelled
ci / e2e (standalone, stable) (push) Has been cancelled
merge / bin-image-prepare (push) Has been cancelled
merge / module-image (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
ci / binary-finalize (push) Has been cancelled
ci / coverage (push) Has been cancelled
ci / release (push) Has been cancelled
merge / bin-image (push) Has been cancelled
merge / desktop-edge-test (push) Has been cancelled
Some checks failed
ci / validate (lint) (push) Has been cancelled
ci / validate (validate-docs) (push) Has been cancelled
ci / validate (validate-go-mod) (push) Has been cancelled
ci / validate (validate-headers) (push) Has been cancelled
ci / binary (push) Has been cancelled
ci / bin-image-test (push) Has been cancelled
ci / test (push) Has been cancelled
ci / e2e (plugin, oldstable) (push) Has been cancelled
ci / e2e (standalone, oldstable) (push) Has been cancelled
ci / e2e (plugin, stable) (push) Has been cancelled
ci / e2e (standalone, stable) (push) Has been cancelled
merge / bin-image-prepare (push) Has been cancelled
merge / module-image (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
ci / binary-finalize (push) Has been cancelled
ci / coverage (push) Has been cancelled
ci / release (push) Has been cancelled
merge / bin-image (push) Has been cancelled
merge / desktop-edge-test (push) Has been cancelled
runPreStart executes a service's pre_start hooks sequentially as ephemeral containers that share the first non-running replica's volumes via VolumesFrom and attach to the same networks. A non-zero hook exit gates service start. per_replica: false is the only currently supported mode; per_replica: true is rejected up front. The donor replica is the lowest-numbered one so the choice is deterministic. ContainerWait uses WaitConditionNextExit, and the wait loop deterministically handles the daemon's clean-close (nil on Error + exit code on Result) and transport-error races to avoid spurious hook failures. The log stream is opened before ContainerStart to avoid racing AutoRemove on fast-exiting hooks, and runs under a derived context so a daemon that keeps the connection open cannot deadlock the call. Hook containers carry project/service/version labels; the two cleanup paths force-remove the never-started container explicitly and warn when removal fails. pre_start runs once per service when no replica is already running (initial up, force-recreate or spec change), and is skipped on scale-up so additional replicas don't re-trigger the hooks. Coverage: 11 unit tests (including scheduler-race stress) with goroutine-leak verification via goleak, plus 10 E2E tests (success path, hook failure gating start, build-image inheritance, idempotent re-up, spec change, force-recreate, mid-sequence failure, ordering, scale-up, scaled service). Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
parent
943f8c929c
commit
9d444a667a
15 changed files with 1070 additions and 30 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
err = s.injectSecrets(ctx, project, service, ctr.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.injectConfigs(ctx, project, service, ctr.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for _, hook := range service.PostStart {
|
||||
err = s.runHook(ctx, ctr, service, hook, listener)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.events.On(newEvent(eventName, api.Done, api.StatusStarted))
|
||||
serviceContainers := containers.filter(isService(service.Name), isNotOneOff)
|
||||
toStart := serviceContainers.filter(isNotRunning)
|
||||
if len(toStart) == 0 {
|
||||
return 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
|
||||
}
|
||||
}
|
||||
|
||||
for _, ctr := range toStart {
|
||||
if err := s.startServiceContainer(ctx, project, service, ctr, listener); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
301
pkg/compose/pre_start.go
Normal file
301
pkg/compose/pre_start.go
Normal file
|
|
@ -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
|
||||
}
|
||||
369
pkg/compose/pre_start_test.go
Normal file
369
pkg/compose/pre_start_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
17
pkg/e2e/fixtures/pre_start/Dockerfile
Normal file
17
pkg/e2e/fixtures/pre_start/Dockerfile
Normal file
|
|
@ -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
|
||||
12
pkg/e2e/fixtures/pre_start/compose-build.yaml
Normal file
12
pkg/e2e/fixtures/pre_start/compose-build.yaml
Normal file
|
|
@ -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:
|
||||
7
pkg/e2e/fixtures/pre_start/compose-error.yaml
Normal file
7
pkg/e2e/fixtures/pre_start/compose-error.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
services:
|
||||
sample:
|
||||
image: alpine
|
||||
command: sh -c 'sleep 30'
|
||||
pre_start:
|
||||
- image: alpine
|
||||
command: sh -c 'exit 17'
|
||||
11
pkg/e2e/fixtures/pre_start/compose-success.yaml
Normal file
11
pkg/e2e/fixtures/pre_start/compose-success.yaml
Normal file
|
|
@ -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:
|
||||
11
pkg/e2e/fixtures/pre_start/idempotent/compose.yaml
Normal file
11
pkg/e2e/fixtures/pre_start/idempotent/compose.yaml
Normal file
|
|
@ -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:
|
||||
13
pkg/e2e/fixtures/pre_start/mid-failure/compose.yaml
Normal file
13
pkg/e2e/fixtures/pre_start/mid-failure/compose.yaml
Normal file
|
|
@ -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:
|
||||
13
pkg/e2e/fixtures/pre_start/scaled/compose.yaml
Normal file
13
pkg/e2e/fixtures/pre_start/scaled/compose.yaml
Normal file
|
|
@ -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:
|
||||
13
pkg/e2e/fixtures/pre_start/sequential/compose.yaml
Normal file
13
pkg/e2e/fixtures/pre_start/sequential/compose.yaml
Normal file
|
|
@ -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:
|
||||
11
pkg/e2e/fixtures/pre_start/spec-change/compose.v1.yaml
Normal file
11
pkg/e2e/fixtures/pre_start/spec-change/compose.v1.yaml
Normal file
|
|
@ -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:
|
||||
11
pkg/e2e/fixtures/pre_start/spec-change/compose.v2.yaml
Normal file
11
pkg/e2e/fixtures/pre_start/spec-change/compose.v2.yaml
Normal file
|
|
@ -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:
|
||||
|
|
@ -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 <file>` stdout, whose
|
||||
// shape is "<count> <filename>". 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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue