From 622e054ab44267703303b1aa67a67058019290ee Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 22 Feb 2026 19:56:46 -0800 Subject: [PATCH] fix: execute post_start hooks in docker compose run RunOneOffContainer was not executing post_start lifecycle hooks after starting a container. This adds hook execution by listening for the container's start event via the Docker Events API and running hooks once the container is running, matching the behavior already present in startService (used by docker compose up) and restart. Signed-off-by: Varun Chawla --- pkg/compose/run.go | 97 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/pkg/compose/run.go b/pkg/compose/run.go index 02ff02c45b..7ca080264d 100644 --- a/pkg/compose/run.go +++ b/pkg/compose/run.go @@ -27,14 +27,22 @@ import ( "github.com/compose-spec/compose-go/v2/types" "github.com/docker/cli/cli" cmd "github.com/docker/cli/cli/command/container" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/events" "github.com/moby/moby/client" "github.com/moby/moby/client/pkg/stringid" "github.com/docker/compose/v5/pkg/api" ) +type prepareRunResult struct { + containerID string + service types.ServiceConfig + created container.Summary +} + func (s *composeService) RunOneOffContainer(ctx context.Context, project *types.Project, opts api.RunOptions) (int, error) { - containerID, err := s.prepareRun(ctx, project, opts) + result, err := s.prepareRun(ctx, project, opts) if err != nil { return 0, err } @@ -44,15 +52,35 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. sigc := make(chan os.Signal, 128) signal.Notify(sigc) - go cmd.ForwardAllSignals(ctx, s.apiClient(), containerID, sigc) + go cmd.ForwardAllSignals(ctx, s.apiClient(), result.containerID, sigc) defer signal.Stop(sigc) + // If the service has post_start hooks, set up a goroutine that waits for + // the container to start and then executes them. This is needed because + // cmd.RunStart both starts and attaches to the container in one call, + // so we can't run hooks sequentially between start and attach. + var hookErrCh chan error + if len(result.service.PostStart) > 0 { + hookErrCh = make(chan error, 1) + go func() { + hookErrCh <- s.runPostStartHooksOnEvent(ctx, result.containerID, result.service, result.created) + }() + } + err = cmd.RunStart(ctx, s.dockerCli, &cmd.StartOptions{ OpenStdin: !opts.Detach && opts.Interactive, Attach: !opts.Detach, - Containers: []string{containerID}, + Containers: []string{result.containerID}, DetachKeys: s.configFile().DetachKeys, }) + + // Wait for hooks to complete if they were started + if hookErrCh != nil { + if hookErr := <-hookErrCh; hookErr != nil && err == nil { + err = hookErr + } + } + var stErr cli.StatusError if errors.As(err, &stErr) { return stErr.StatusCode, nil @@ -60,29 +88,60 @@ func (s *composeService) RunOneOffContainer(ctx context.Context, project *types. return 0, err } -func (s *composeService) prepareRun(ctx context.Context, project *types.Project, opts api.RunOptions) (string, error) { +// runPostStartHooksOnEvent listens for the container's start event and executes +// post_start lifecycle hooks once the container is running. +func (s *composeService) runPostStartHooksOnEvent(ctx context.Context, containerID string, service types.ServiceConfig, ctr container.Summary) error { + evtCtx, cancel := context.WithCancel(ctx) + defer cancel() + + res := s.apiClient().Events(evtCtx, client.EventsListOptions{ + Filters: make(client.Filters). + Add("type", "container"). + Add("container", containerID). + Add("event", string(events.ActionStart)), + }) + + // Wait for the container start event + select { + case <-evtCtx.Done(): + return evtCtx.Err() + case err := <-res.Err: + return err + case <-res.Messages: + // Container started, run hooks + } + + for _, hook := range service.PostStart { + if err := s.runHook(ctx, ctr, service, hook, nil); err != nil { + return err + } + } + return nil +} + +func (s *composeService) prepareRun(ctx context.Context, project *types.Project, opts api.RunOptions) (prepareRunResult, error) { // Temporary implementation of use_api_socket until we get actual support inside docker engine project, err := s.useAPISocket(project) if err != nil { - return "", err + return prepareRunResult{}, err } err = Run(ctx, func(ctx context.Context) error { return s.startDependencies(ctx, project, opts) }, "run", s.events) if err != nil { - return "", err + return prepareRunResult{}, err } service, err := project.GetService(opts.Service) if err != nil { - return "", err + return prepareRunResult{}, err } applyRunOptions(project, &service, opts) if err := s.stdin().CheckTty(opts.Interactive, service.Tty); err != nil { - return "", err + return prepareRunResult{}, err } slug := stringid.GenerateRandomID() @@ -102,17 +161,17 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, // Only ensure image exists for the target service, dependencies were already handled by startDependencies buildOpts := prepareBuildOptions(opts) if err := s.ensureImagesExists(ctx, project, buildOpts, opts.QuietPull); err != nil { // all dependencies already checked, but might miss service img - return "", err + return prepareRunResult{}, err } observedState, err := s.getContainers(ctx, project.Name, oneOffInclude, true) if err != nil { - return "", err + return prepareRunResult{}, err } if !opts.NoDeps { if err := s.waitDependencies(ctx, project, service.Name, service.DependsOn, observedState, 0); err != nil { - return "", err + return prepareRunResult{}, err } } createOpts := createOptions{ @@ -124,31 +183,35 @@ func (s *composeService) prepareRun(ctx context.Context, project *types.Project, err = newConvergence(project.ServiceNames(), observedState, nil, nil, s).resolveServiceReferences(&service) if err != nil { - return "", err + return prepareRunResult{}, err } err = s.ensureModels(ctx, project, opts.QuietPull) if err != nil { - return "", err + return prepareRunResult{}, err } created, err := s.createContainer(ctx, project, service, service.ContainerName, -1, createOpts) if err != nil { - return "", err + return prepareRunResult{}, err } inspect, err := s.apiClient().ContainerInspect(ctx, created.ID, client.ContainerInspectOptions{}) if err != nil { - return "", err + return prepareRunResult{}, err } err = s.injectSecrets(ctx, project, service, inspect.Container.ID) if err != nil { - return created.ID, err + return prepareRunResult{containerID: created.ID}, err } err = s.injectConfigs(ctx, project, service, inspect.Container.ID) - return created.ID, err + return prepareRunResult{ + containerID: created.ID, + service: service, + created: created, + }, err } func prepareBuildOptions(opts api.RunOptions) *api.BuildOptions {