fix(run): scope Running events to project.Services

emitRunningEvents iterated the full ObservedState.Containers map,
which is intentionally broader than the operation scope (it covers
DisabledServices for orphan classification). compose run --no-deps
SERVICE leaves project.Services empty and moves every other service
to DisabledServices, so their running containers were reported as
Running even though this command must not manage them.

Filter the iteration by project.Services, matching the reconciler
scope, and document the contract on the function.

Fixes #13882

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-06-29 11:46:56 +02:00 committed by Guillaume Lours
parent e87f7b79df
commit ab0d7898de
3 changed files with 108 additions and 5 deletions

View file

@ -124,7 +124,7 @@ func (s *composeService) create(ctx context.Context, project *types.Project, opt
// Emit "Running" events for containers that are already up-to-date,
// matching the previous convergence behavior for progress display.
emitRunningEvents(observed, plan, s.events)
emitRunningEvents(project, observed, plan, s.events)
return s.executePlan(ctx, project, observed, plan)
}

View file

@ -213,8 +213,11 @@ func (s *ObservedState) setResolvedVolumes(volumes map[string]string) {
// emitRunningEvents emits "Running" progress events for containers that are already
// running and have no operations planned for them. This matches the previous behavior
// where convergence.ensureService emitted runningEvent for up-to-date containers.
func emitRunningEvents(observed *ObservedState, plan *Plan, events api.EventProcessor) {
// Collect all container IDs that appear in the plan
//
// Iterates project.Services (not observed.Containers) so that containers of
// disabled services (e.g. dependencies untouched by `compose run --no-deps`)
// are not falsely reported as Running — see issue 13882.
func emitRunningEvents(project *types.Project, observed *ObservedState, plan *Plan, events api.EventProcessor) {
planned := map[string]bool{}
for _, node := range plan.Nodes {
if node.Operation.Container != nil {
@ -222,8 +225,8 @@ func emitRunningEvents(observed *ObservedState, plan *Plan, events api.EventProc
}
}
for _, containers := range observed.Containers {
for _, oc := range containers {
for _, svc := range project.Services {
for _, oc := range observed.Containers[svc.Name] {
if oc.State == container.StateRunning && !planned[oc.ID] {
events.On(newEvent("Container "+oc.Name, api.Done, api.StatusRunning))
}

View file

@ -201,3 +201,103 @@ func TestCollectObservedState(t *testing.T) {
assert.Equal(t, vol.Driver, "local")
assert.Equal(t, vol.ConfigHash, "volhash1")
}
type capturingEvents struct {
noopEventProcessor
resources []api.Resource
}
func (c *capturingEvents) On(events ...api.Resource) {
c.resources = append(c.resources, events...)
}
func TestEmitRunningEvents(t *testing.T) {
runningWeb := ObservedContainer{ID: "c-web", Name: "p-web-1", State: container.StateRunning}
runningDB := ObservedContainer{ID: "c-db", Name: "p-db-1", State: container.StateRunning}
runningDisabled := ObservedContainer{ID: "c-misc", Name: "p-misc-1", State: container.StateRunning}
exitedWeb := ObservedContainer{ID: "c-web-old", Name: "p-web-2", State: container.StateExited}
t.Run("emits Running for active services not in plan", func(t *testing.T) {
project := &types.Project{
Services: types.Services{
"web": {Name: "web"},
"db": {Name: "db"},
},
}
observed := &ObservedState{
Containers: map[string][]ObservedContainer{
"web": {runningWeb},
"db": {runningDB},
},
}
events := &capturingEvents{}
emitRunningEvents(project, observed, &Plan{}, events)
assert.Equal(t, len(events.resources), 2)
names := map[string]bool{}
for _, r := range events.resources {
names[r.ID] = true
assert.Equal(t, r.Text, api.StatusRunning)
}
assert.Assert(t, names["Container p-web-1"])
assert.Assert(t, names["Container p-db-1"])
})
t.Run("skips containers belonging to disabled services", func(t *testing.T) {
// Reproduces issue 13882: `compose run --no-deps misc` leaves the
// project with project.Services empty (misc moved to DisabledServices).
// Running containers for disabled services must not be reported.
project := &types.Project{
Services: types.Services{},
DisabledServices: types.Services{
"misc": {Name: "misc"},
"db": {Name: "db"},
},
}
observed := &ObservedState{
Containers: map[string][]ObservedContainer{
"misc": {runningDisabled},
"db": {runningDB},
},
}
events := &capturingEvents{}
emitRunningEvents(project, observed, &Plan{}, events)
assert.Equal(t, len(events.resources), 0)
})
t.Run("skips containers that have a plan operation", func(t *testing.T) {
project := &types.Project{
Services: types.Services{"web": {Name: "web"}},
}
observed := &ObservedState{
Containers: map[string][]ObservedContainer{"web": {runningWeb}},
}
plan := &Plan{}
plan.addNode(Operation{
Type: OpStopContainer,
Container: &container.Summary{ID: runningWeb.ID},
}, "")
events := &capturingEvents{}
emitRunningEvents(project, observed, plan, events)
assert.Equal(t, len(events.resources), 0)
})
t.Run("skips non-running containers", func(t *testing.T) {
project := &types.Project{
Services: types.Services{"web": {Name: "web"}},
}
observed := &ObservedState{
Containers: map[string][]ObservedContainer{"web": {exitedWeb}},
}
events := &capturingEvents{}
emitRunningEvents(project, observed, &Plan{}, events)
assert.Equal(t, len(events.resources), 0)
})
}