fix: start lists service containers through getContainers

The start flow hand-rolled its ContainerList without the ConfigHashLabel
presence filter every other listing applies (getDefaultFilters) — the
actual source of pre_start hook runners leaking into startService and
waitDependencies, patched downstream by isNotHookContainer. Routing
through getContainers fixes the leak at the source, like
getContainersByService already does; the isNotHookContainer guards stay
as defense-in-depth.

The dry-run client keyed its ContainerList interception on the calling
function: caller "start" answered from the faked-creations cache, while
caller "getContainers" fell back to the real daemon once the cache was
seeded — so routing start through getContainers made dry-run-created
containers invisible to the start phase ("service has no container to
start", TestUpDryRunMissingImage). The getContainers case now keeps
answering from the seeded cache, where faked creations land.

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-09-30 12:03:10 +02:00
committed by Nicolas De loof
parent 329b3ad548
commit 730a785bd4
2 changed files with 27 additions and 21 deletions
+5 -6
View File
@@ -23,7 +23,6 @@ import (
"strings"
"github.com/compose-spec/compose-go/v2/types"
"github.com/moby/moby/client"
"github.com/docker/compose/v5/pkg/api"
)
@@ -53,14 +52,14 @@ func (s *composeService) start(ctx context.Context, projectName string, options
// and the dependency waits read them
project = project.WithoutUnresolvedOptionalDependencies()
res, err := s.apiClient().ContainerList(ctx, client.ContainerListOptions{
Filters: projectFilter(project.Name).Add("label", oneOffFilter(false)),
All: true,
})
// getContainers filters on ConfigHashLabel presence (getDefaultFilters),
// which every service container carries and hook runners deliberately do
// not: pre_start runners never leak into the start flow at the source
// (isNotHookContainer downstream stays as defense-in-depth).
containers, err := s.getContainers(ctx, project.Name, oneOffExclude, true)
if err != nil {
return err
}
containers := Containers(res.Items)
err = InDependencyOrder(ctx, project, func(c context.Context, name string) error {
service, err := project.GetService(name)
+22 -15
View File
@@ -47,10 +47,13 @@ var _ client.APIClient = &DryRunClient{}
// DryRunClient implements APIClient by delegating to implementation functions. This allows lazy init and per-method overrides
type DryRunClient struct {
apiClient client.APIClient
containers []containerType.Summary
execs sync.Map
configFile *configfile.ConfigFile
apiClient client.APIClient
// containers caches the project's containers: seeded once from the real
// daemon (containersSeeded), then extended with every faked creation.
containers []containerType.Summary
containersSeeded bool
execs sync.Map
configFile *configfile.ConfigFile
}
type execDetails struct {
@@ -182,20 +185,24 @@ func (d *DryRunClient) ContainerKill(ctx context.Context, container string, opti
func (d *DryRunClient) ContainerList(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) {
caller := getCallingFunction()
switch caller {
case "start":
case "getContainers":
// Seed the cache from the real daemon on first use, then keep
// answering from it: containers faked by ContainerCreate must stay
// visible to the start flow, which lists through getContainers.
// Seeding is tracked independently of the cache's length — a faked
// creation may land before the first listing, and must not mask the
// daemon's real containers.
if !d.containersSeeded {
res, err := d.apiClient.ContainerList(ctx, options)
if err != nil {
return client.ContainerListResult{}, err
}
d.containersSeeded = true
d.containers = append(d.containers, res.Items...)
}
return client.ContainerListResult{
Items: d.containers,
}, nil
case "getContainers":
if len(d.containers) == 0 {
res, err := d.apiClient.ContainerList(ctx, options)
if err == nil {
d.containers = res.Items
}
return client.ContainerListResult{
Items: d.containers,
}, err
}
case "listPreStartRunners":
// Hook runners created under dry-run exist only in the cache: answer
// from it, honoring the caller's label filters (project, service,