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 <varun_6april@hotmail.com>
This commit is contained in:
Varun Chawla 2026-02-22 19:56:46 -08:00 committed by Nicolas De loof
parent f9828dfab9
commit 81d7d3c60b

View file

@ -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 {