diff --git a/pkg/compose/executor.go b/pkg/compose/executor.go index c10b25962d..182d8456c7 100644 --- a/pkg/compose/executor.go +++ b/pkg/compose/executor.go @@ -39,11 +39,10 @@ type planExecutor struct { project *types.Project pctx *reconciliationContext - // listener streams pre_start/post_start hook logs, exactly like the - // imperative start path. It is set once, when the Start phase is entered - // (planRun.runStart): an attached caller only has one by then, its printer - // not existing before the Create phase is done. nil leaves hook execution - // silent, matching detached behavior. + // listener streams pre_start/post_start hook logs. It is set once, when + // the Start phase is entered (planRun.runStart): an attached caller only + // has one by then, its printer not existing before the Create phase is + // done. nil leaves hook execution silent, matching detached behavior. listener api.ContainerEventListener // containersByService is a live view used to resolve service references @@ -379,8 +378,9 @@ func (p *phaseCancel) fail(node *PlanNode, events api.EventProcessor, groups *gr // latter needs a dependency that is already running, hence with no start // node for the wait to depend on, whose health relies on a sibling the plan // has yet to start: with a bounded --parallel the wait could take the slot -// that sibling's start needs and only end at its timeout. The imperative -// waitDependencies stays outside the cap for the same reason. +// that sibling's start needs and only end at its timeout. The waits +// waitDependencies runs outside any plan stay outside the cap for the same +// reason. func acquireNodeSlot(ctx context.Context, limiter *semaphore.Weighted, node *PlanNode) (func(), error) { if node.Operation.Type == OpWaitCondition { return func() {}, nil @@ -550,9 +550,9 @@ func (e *waitInterrupted) Error() string { return "wait interrupted" } // translateWaitTimeout turns a plan failure caused by a wait node's own // --wait-timeout window (see errWaitTimeout) into the "timeout waiting for -// dependencies" message the imperative start path always reported for a -// depends_on wait timing out, with or without --wait, and leaves any other -// error as is. It is deliberately not the "application not healthy" message: +// dependencies" message a depends_on wait timing out has always reported, +// with or without --wait, and leaves any other error as is. It is +// deliberately not the "application not healthy" message: // that one belongs to the final --wait readiness check that runs after the // plan, which has its own translation. func translateWaitTimeout(err error) error { diff --git a/pkg/compose/executor_events.go b/pkg/compose/executor_events.go index cc2bf4e7c1..c10a96e5cc 100644 --- a/pkg/compose/executor_events.go +++ b/pkg/compose/executor_events.go @@ -147,9 +147,8 @@ func (gt *groupTracker) onNodeStart(node *PlanNode, events api.EventProcessor) b // triggersWorking reports whether this node starting should fire the group's // Working event. A recreate group fires on its first node; a start group // fires specifically on OpStartContainer — pre_start hooks, when planned, -// run silently before it, matching the imperative engine's startService, -// which never surfaces a Starting event until the ContainerStart call itself -// begins. +// run silently before it: no Starting event is surfaced until the +// ContainerStart call itself begins. func (gs *groupState) triggersWorking(t OperationType) bool { if gs.kind == groupStart { return t == OpStartContainer diff --git a/pkg/compose/executor_ops.go b/pkg/compose/executor_ops.go index 05e27622ad..d087489353 100644 --- a/pkg/compose/executor_ops.go +++ b/pkg/compose/executor_ops.go @@ -133,7 +133,7 @@ func (exec *planExecutor) execCreateContainer(ctx context.Context, node *PlanNod // today only the create phase's exceptional-state restart of a paused/dead // container) is a plain ContainerStart. A start-phase operation (Service set) // performs the full service start — secret/config injection right before -// ContainerStart — mirroring startServiceContainer; its target resolves +// ContainerStart; its target resolves // either from the observed container or, for a replica the plan itself // creates, from the CreateContainer node's result (the same mechanism // OpRenameContainer already uses). @@ -213,9 +213,9 @@ func (exec *planExecutor) resolveContainerSummary(op Operation) (container.Summa // execWaitCondition polls the dependency service named by the operation // until it satisfies the declared depends_on condition or the context ends — // the plan-side equivalent of one waitDependencies edge, reusing the exact -// same polling primitive (waitDependency) and per-condition checks the -// imperative engine uses, so events and error messages cannot drift between -// the two. required: false dependencies are marked BestEffort by the +// same polling primitive (waitDependency) and per-condition checks as +// waitDependencies, so events and error messages cannot drift between the +// two. required: false dependencies are marked BestEffort by the // reconciler: a missing dependency or a failed/timed-out condition is then a // warning, not a plan failure — matching waitDependencies' own // optional-dependency handling. diff --git a/pkg/compose/executor_start_test.go b/pkg/compose/executor_start_test.go index ba951c5d1f..2bfc1448f5 100644 --- a/pkg/compose/executor_start_test.go +++ b/pkg/compose/executor_start_test.go @@ -114,7 +114,7 @@ func TestExecWaitCondition_OptionalMissingDependencyIsTolerated(t *testing.T) { // TestExecWaitCondition_HealthyConditionSatisfied drives execWaitCondition // against a live dependency container that reports healthy on the first // probe, reusing the exact same isServiceHealthy/waitDependency primitives -// the imperative engine's waitDependencies uses. +// waitDependencies uses. func TestExecWaitCondition_HealthyConditionSatisfied(t *testing.T) { svc, apiClient, recorder := newStartPhaseTestService(t) @@ -154,9 +154,7 @@ func TestExecWaitCondition_HealthyConditionSatisfied(t *testing.T) { // TestExecutePlanWaitConditionRespectsContextDeadline is a regression test // for a gap a reviewer caught on upDetached (pkg/compose/up.go): the plan's -// own OpWaitCondition nodes have no timeout of their own (unlike the -// imperative engine's waitDependencies, which already threaded WaitTimeout -// through every per-dependency wait, not just the final --wait check) -- +// own OpWaitCondition nodes have no timeout of their own -- // they rely entirely on whatever ctx they're given (directly, or via // exec.waitTimeout -- see TestExecutePlanWaitDeadlineOnlyBoundsWaitNodes for // that path specifically). This drives a real OpWaitCondition node @@ -1020,8 +1018,7 @@ func TestExecutePlanStartPhaseEventContract(t *testing.T) { // container.Summary — CreateNodeID only), exercising resolveContainerSummary's // live-view lookup. It asserts the group emits exactly one Starting→Started // pair, with Starting firing at the StartContainer node — not at -// RunPreStart, which must run silently first, matching the imperative -// engine's startService. +// RunPreStart, which must run silently first. func TestExecutePlanStartPhaseFullChainThroughCreateNode(t *testing.T) { svc, apiClient, recorder := newStartPhaseTestService(t) diff --git a/pkg/compose/executor_test.go b/pkg/compose/executor_test.go index e8da0c64f5..dcde72f8a4 100644 --- a/pkg/compose/executor_test.go +++ b/pkg/compose/executor_test.go @@ -227,7 +227,7 @@ func emptyObservedState(project string) *ObservedState { // wiring and group tracker — not a hand-rolled loop over executeNode. // A best-effort removal failure (stale pre_start hook runner purge) is // warn-only: the plan carries on, and the removal passes RemoveVolumes so the -// runner's anonymous volumes go with it — the imperative purge semantics. +// runner's anonymous volumes go with it. func TestExecutePlanBestEffortRemoveContainerFailureTolerated(t *testing.T) { svc, apiClient := newTestService(t) diff --git a/pkg/compose/observed_state.go b/pkg/compose/observed_state.go index 0d4c088c0f..026c932b4e 100644 --- a/pkg/compose/observed_state.go +++ b/pkg/compose/observed_state.go @@ -215,8 +215,8 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type // phase's own RunPreStart never reads observed.HookContainers, it // executes a runner the create phase already prepared or fails outright // if none exists. Skipping this for ScopeStart matches the - // network/volume guard below: one more unconditional daemon call the old - // imperative engine never made, which could otherwise fail a no-op + // network/volume guard below: one more unconditional daemon call a start + // never made, which could otherwise fail a no-op // `compose start` on a service with pre_start hooks declared (second // Copilot review finding in the same vein as the first). if scope.plansCreatePhase() { @@ -265,8 +265,8 @@ func (s *composeService) collectObservedState(ctx context.Context, project *type // reconcileNetworks/reconcileVolumes, which ScopeStart never runs, do). // Skipping these calls for ScopeStart isn't just an optimization -- a // Copilot review finding on #14296 noted that querying them unconditionally - // gives `compose start` new daemon dependencies the old imperative engine - // never had (only ever called getContainers): an authorization plugin + // gives `compose start` new daemon dependencies it never had (it only ever + // called getContainers): an authorization plugin // permitting container list/start but denying network or volume listing // would now make `compose start` fail before starting anything. if scope.plansCreatePhase() { diff --git a/pkg/compose/observed_state_test.go b/pkg/compose/observed_state_test.go index 7b05ae36d9..8926569585 100644 --- a/pkg/compose/observed_state_test.go +++ b/pkg/compose/observed_state_test.go @@ -273,8 +273,8 @@ func TestCollectObservedState(t *testing.T) { // test for a Copilot review finding on #14296: ScopeStart's planStartPhase // never reads resolvedNetworks/resolvedVolumes (only reconcileNetworks/ // reconcileVolumes, which ScopeStart never runs, do), so querying them -// unconditionally gave `compose start` new daemon dependencies the old -// imperative engine never had (only ever called getContainers) -- a problem +// unconditionally gave `compose start` new daemon dependencies it never had +// (it only ever called getContainers) -- a problem // for an authorization plugin permitting container list/start but denying // network or volume listing. No NetworkList/VolumeList/NetworkInspect/ // VolumeInspect expectation is registered here: gomock fails the test if diff --git a/pkg/compose/plan.go b/pkg/compose/plan.go index 22efc8afe0..2a0e361b79 100644 --- a/pkg/compose/plan.go +++ b/pkg/compose/plan.go @@ -134,14 +134,14 @@ type Operation struct { Condition string // for OpWaitCondition: depends_on condition to wait for (service_healthy, ...) HookIndex int // for OpCreateHookContainer: position of the hook in the service's hook list // RemoveVolumes asks OpRemoveContainer to also remove the container's - // anonymous volumes — the imperative semantics for hook-runner containers. + // anonymous volumes — what removing a hook-runner container has always done. RemoveVolumes bool // BestEffort marks an operation whose failure must not abort the plan. // Used for the optional removal of the old network on a rename (if the // network is still in use by non-Compose containers the removal is skipped // with a warning — the new network already carries a different name), and - // for purging stale pre_start hook runners (the imperative purge is - // warn-only: a failed removal leaves the container visible, never blocks). + // for purging stale pre_start hook runners (a failed removal only warns: + // it leaves the container visible, never blocks). BestEffort bool } diff --git a/pkg/compose/reconcile.go b/pkg/compose/reconcile.go index bfb6eb2c78..fe1886b06a 100644 --- a/pkg/compose/reconcile.go +++ b/pkg/compose/reconcile.go @@ -122,8 +122,8 @@ type reconciler struct { // r.project is never reassigned between the two calls. dependencyGraph *Graph // removedByPlan records replicas the create phase condemns (scale-down - // stop+remove): the start phase must never plan a start for them — the - // imperative engine only ever starts what survives the convergence. + // stop+remove): the start phase must never plan a start for them — only + // what survives the convergence is started. removedByPlan map[string]bool // hookRunnerNodes records, per service, the create-phase nodes that // materialize the pre_start hook runners, so the start-phase RunPreStart @@ -960,9 +960,9 @@ func (r *reconciler) reconcileService(service types.ServiceConfig) error { // per_replica return below. The start phase only executes runners prepared // here; it never creates one itself. // -// Purges are best-effort (mirroring the historical imperative purge, which was -// warn-only) but the creates depend on them: runner names are deterministic, -// so a genuinely stuck old runner surfaces as a name conflict on the create. +// Purges are best-effort (a failed removal only warns) but the creates depend +// on them: runner names are deterministic, so a genuinely stuck old runner +// surfaces as a name conflict on the create. // The creates also depend on every replica node and on the infrastructure, so // the executor resolves the target replica (VolumesFrom) against a final live // view with networks in place. When a running replica survives the plan, the @@ -1138,12 +1138,11 @@ func (r *reconciler) hasVolumeMismatch(expected types.ServiceConfig, oc Observed } // planStartPhase appends the Start phase to the plan: for every service, a -// replica chain of start-phase operations reproducing the imperative -// engine's semantics — dependency conditions first, one optional -// OpRunPreStart when no replica was running at observation, then per +// replica chain of start-phase operations — dependency conditions first, one +// optional OpRunPreStart when no replica was running at observation, then per // replica inject+start (OpStartContainer, enriched by the executor) and // post_start hooks, each replica chained after the previous one to keep -// today's sequential start order, now visible in the plan. +// the sequential start order, visible in the plan. // // Conditions other than service_started materialize as OpWaitCondition // nodes, deduplicated per (service, condition) across dependents; health is @@ -1257,12 +1256,12 @@ func (r *reconciler) exceptionalStateRestartNodes(service string) []*PlanNode { // startPhaseReplicas collects the replicas to start, ascending number: // containers materialized by the create phase plus observed up-to-date -// containers not running — exactly the isNotRunning set the imperative start -// phase acts on. anyRunning reports whether a replica will be running when +// containers not running — exactly the set a start acts on. anyRunning +// reports whether a replica will be running when // the start phase looks: running at observation and left untouched by the // plan, or brought to running by the create phase itself (the bare restart -// of exceptional states). Both gate pre_start and take no start node — the -// imperative engine neither re-starts nor injects into a running container. +// of exceptional states). Both gate pre_start and take no start node — a +// running container is neither re-started nor injected into. func (r *reconciler) startPhaseReplicas(service types.ServiceConfig) (replicas []startReplica, anyRunning bool) { planned := r.containerNodes[service.Name] seen := map[int]bool{} @@ -1271,7 +1270,7 @@ func (r *reconciler) startPhaseReplicas(service types.ServiceConfig) (replicas [ resID := serviceReplicaID(service.Name, oc.Number) if r.removedByPlan[resID] { // condemned by the create phase (scale-down): gone by the time - // the imperative start phase would look, so it neither starts + // the start phase would look, so it neither starts // nor counts as running for the pre_start gating seen[oc.Number] = true continue @@ -1291,10 +1290,9 @@ func (r *reconciler) startPhaseReplicas(service types.ServiceConfig) (replicas [ if stopNode, stopped := r.stoppedByPlan[oc.ID]; stopped { // the create phase stops this container WITHOUT recreating // it (network recreate): it will be down when the start - // phase looks, and the imperative engine restarts it from - // its second snapshot — so does the plan, ordered after the + // phase looks, so the plan restarts it, ordered after the // container's last reconnect (else its stop). Not running - // for the pre_start gating, like in the imperative engine. + // for the pre_start gating. after := stopNode if reconnects := r.connectNodes[oc.ID]; len(reconnects) > 0 { after = reconnects[len(reconnects)-1] @@ -1324,9 +1322,9 @@ func (r *reconciler) startPhaseReplicas(service types.ServiceConfig) (replicas [ // prerequisites: a service_started condition is a plain edge to the // dependency's chain end, any other condition a deduplicated OpWaitCondition // node re-evaluated at execution time. Which dependencies need a wait node -// is delegated to shouldWaitForDependency — the imperative engine's own -// gate — errors included: a dependency that is neither declared nor -// disabled is the same hard failure it is today. +// is delegated to shouldWaitForDependency — the gate waitDependencies shares +// — errors included: a dependency that is neither declared nor disabled is a +// hard failure. func (r *reconciler) startPhaseDependencies(service types.ServiceConfig) ([]*PlanNode, error) { var depNodes []*PlanNode for _, dep := range sortedKeys(service.DependsOn) { @@ -1386,9 +1384,9 @@ func (r *reconciler) waitConditionNode(dep string, cfg types.ServiceDependency, } // planProviderStart is the start-phase visit of a provider service: no -// container to start, but its own depends_on still applies — the imperative -// startService waits on it for every service, providers included. Its chain -// end is its create-phase RunProvider node (the deployment itself), plus +// container to start, but its own depends_on still applies, as for every +// service, providers included. Its chain end is its create-phase +// RunProvider node (the deployment itself), plus // those dependency prerequisites when it has any -- a consumer must wait for // both: the provider's own dependencies satisfied AND the provider actually // deployed, not either in isolation. Before this ordering was corrected, a @@ -1431,8 +1429,8 @@ func (r *reconciler) planProviderStart(service types.ServiceConfig) error { // planProviderRelayRestart plans the start of the provider's own observed, // not-running containers when the create phase didn't already converge them -// (see planProviderStart's caller). The imperative engine decided -// bare-vs-enriched PER CONTAINER (isRelayContainer), never from the service's +// (see planProviderStart's caller). Bare-vs-enriched is decided +// PER CONTAINER (isRelayContainer), never from the service's // currently declared type: a provider: declaration can change without an // intervening `up`/create, so under pure ScopeStart the observed container // can be the real relay (bare start) or a stale non-relay replica left over @@ -1467,8 +1465,8 @@ func (r *reconciler) planServiceStart(service types.ServiceConfig) error { if service.Provider != nil { return r.planProviderStart(service) } - // imperative parity (startService): deploy.replicas: 0 is an unconditional - // no-op, but a service with scale: 0 is still visited -- its depends_on + // deploy.replicas: 0 is an unconditional no-op, but a service with + // scale: 0 is still visited -- its depends_on // conditions are evaluated and its dependents ordered after them -- and // whatever replicas it already owns are started (scope Start only: the // create phase removes them all under CreateStart). It is just never an @@ -1479,17 +1477,17 @@ func (r *reconciler) planServiceStart(service types.ServiceConfig) error { replicas, anyRunning := r.startPhaseReplicas(service) // dependency conditions are evaluated even when nothing has to start: - // the imperative engine calls waitDependencies for every visited service - // BEFORE looking at what to start, so an up with everything running - // still fails on an unhealthy required dependency — and a dependency + // every visited service evaluates its dependencies (the plan's wait + // nodes) BEFORE looking at what to start, so an up with everything + // running still fails on an unhealthy required dependency — and a dependency // error wins over "no container to start", in that same order depNodes, err := r.startPhaseDependencies(service) if err != nil { return err } if len(replicas) == 0 && !anyRunning && r.options.Scope == ScopeStart && service.GetScale() > 0 && !r.observedAnyContainer() { - // imperative parity (startService): the "no container to start" check - // is project-wide -- it receives the whole project's container list, + // the "no container to start" check is project-wide -- it looks at + // the whole project's container list, // so a service without a container next to existing ones is a no-op // and only a project with no container at all fails. Only reachable // under scope Start, since CreateStart would have planned the @@ -1519,7 +1517,7 @@ func (r *reconciler) planServiceStart(service types.ServiceConfig) error { // no entry otherwise (ScopeStart, all running, no depends_on): the // visit had no effect, so dependents have nothing to order after — // a service_started condition on a running dependency is already - // satisfied, exactly like the imperative engine's empty visit + // satisfied, as when nothing needs starting return nil } @@ -1538,7 +1536,7 @@ func (r *reconciler) chainStartReplicas(service types.ServiceConfig, replicas [] } // pre_start runs once per service, only when no replica was running at - // observation — the imperative gating (initial up, force-recreate, or + // observation (initial up, force-recreate, or // spec change), decided at plan time. Never against a stale relay left // over from before the service's provider: declaration was removed // (isReplicaRelay) -- it has no shell/process for the hook's VolumesFrom diff --git a/pkg/compose/reconcile_start_test.go b/pkg/compose/reconcile_start_test.go index 1cdc2da73f..501065aed5 100644 --- a/pkg/compose/reconcile_start_test.go +++ b/pkg/compose/reconcile_start_test.go @@ -166,7 +166,7 @@ func TestPlanStart_HooksAndReplicaChain(t *testing.T) { // With a replica already running and untouched by the plan, pre_start is // gated off and the running replica gets no node — only the non-running one -// starts, the imperative isNotRunning role expressed in the plan. +// starts. func TestPlanStart_RunningReplicaGatesPreStart(t *testing.T) { two := 2 service := types.ServiceConfig{ @@ -232,9 +232,9 @@ func TestPlanStart_RecreatedReplicaTargetsCreateNode(t *testing.T) { } // An exceptional-state container (paused, dead, ...) gets its bare -// create-phase restart and NOTHING in the start phase: the imperative engine -// finds it running when the start phase looks, so it neither re-starts nor -// injects — and its being running gates pre_start for the whole service. +// create-phase restart and NOTHING in the start phase: it is running when the +// start phase looks, so it neither re-starts nor injects — and its being +// running gates pre_start for the whole service. func TestPlanStart_ExceptionalStateReplicaCountsAsRunning(t *testing.T) { hook := types.PreStartHook{} hook.Command = []string{"echo", "hi"} @@ -268,9 +268,9 @@ func TestPlanStart_ExceptionalStateReplicaCountsAsRunning(t *testing.T) { assert.Assert(t, createPhase != nil, "the historical bare restart must remain:\n%s", plan) } -// Dependency conditions are re-verified even when nothing has to start — the -// imperative engine calls waitDependencies for every visited service before -// looking at what to start, so an up with everything running still fails on +// Dependency conditions are re-verified even when nothing has to start — every +// visited service evaluates its dependencies before looking at what to start, +// so an up with everything running still fails on // an unhealthy required dependency. The dependent's visit end (the wait) also // orders whoever depends on IT via service_started. func TestPlanStart_AllRunningStillWaitsOnConditions(t *testing.T) { @@ -307,7 +307,7 @@ func TestPlanStart_AllRunningStillWaitsOnConditions(t *testing.T) { assert.Assert(t, !wait.Operation.BestEffort) } -// Imperative parity (startService): a service with scale: 0 is still visited +// A service with scale: 0 is still visited // -- its depends_on conditions are evaluated before the no-op, so an unhealthy // dependency still fails `up`/`start` and whatever is ordered after the // service is ordered after those prerequisites -- and under scope Start the @@ -370,7 +370,7 @@ func TestPlanStart_ScaleZeroIsStillVisited(t *testing.T) { } } -// Imperative parity (startService): under scope Start, a scale>0 service +// Under scope Start, a scale>0 service // with no container at all fails the plan the way `compose start` fails. func TestPlanStart_NoContainerToStartFails(t *testing.T) { project := &types.Project{ @@ -382,7 +382,7 @@ func TestPlanStart_NoContainerToStartFails(t *testing.T) { assert.ErrorContains(t, err, `service "app" has no container to start`) } -// Imperative parity (startService): the "no container to start" check is +// The "no container to start" check is // project-wide. A service without a container next to existing ones is a // no-op for `compose start` (the other services still start); only a project // with no container at all fails. @@ -467,7 +467,7 @@ func TestPlanStart_OrdinaryPathSkipsPreStartWhenLowestIsRelay(t *testing.T) { } // A depends_on naming a service that is neither declared nor disabled is -// the same hard error the imperative engine raises (shouldWaitForDependency +// a hard error (shouldWaitForDependency // propagates it) — reachable via `compose start` without a compose file, // where projectFromName never populates DisabledServices. func TestPlanStart_MissingDependencyFails(t *testing.T) { @@ -487,13 +487,13 @@ func TestPlanStart_MissingDependencyFails(t *testing.T) { assert.ErrorContains(t, err, "ghost") // ordering parity: with NO container at all under scope Start, the - // dependency error still wins over errNoContainerToStart — the - // imperative startService waits on dependencies first + // dependency error still wins over errNoContainerToStart — dependencies + // are evaluated first _, err = reconcile(t.Context(), project, emptyObserved(), startScopeOptions(ScopeStart), noPrompt) assert.ErrorContains(t, err, "ghost") } -// Imperative parity (startService): waitDependencies runs for every service, +// Dependencies are evaluated for every service, // providers included — a provider's own depends_on must produce its wait // nodes instead of being skipped with the container-start machinery. func TestPlanStart_ProviderWaitsOnDependencies(t *testing.T) { @@ -692,7 +692,7 @@ func TestPlanStart_ScaledExceptionalStateConsumerWaitsForEveryRestart(t *testing } // A replica condemned by scale-down must never receive a start-phase node: -// the imperative engine only starts what survives the convergence — and the +// only what survives the convergence is started — and the // condemned replica does not count as running for the pre_start gating. func TestPlanStart_ScaleDownTargetIsNeverStarted(t *testing.T) { one := 1 @@ -730,7 +730,7 @@ func TestPlanStart_ScaleDownTargetIsNeverStarted(t *testing.T) { // A running container the create phase stops WITHOUT recreating (network // recreate) is down when the start phase looks: the plan restarts it — // ordered after its last reconnect — and it does not gate pre_start, exactly -// like the imperative engine restarting it from its second snapshot. +// as the container is down when the start phase looks. func TestStartPhaseReplicas_StoppedByPlanRestarts(t *testing.T) { stop := &PlanNode{ID: 1, Operation: Operation{Type: OpStopContainer}} reconnect := &PlanNode{ID: 2, Operation: Operation{Type: OpConnectNetwork}} @@ -992,9 +992,9 @@ func TestPlanStart_OrdinaryPathBareStartsAStaleRelay(t *testing.T) { // test for the second Copilot review finding on #14296, the symmetric gap: // a service declared provider: whose observed container is NOT a relay (a // stale normal replica left over from before the service became -// provider-backed) must still be started -- the OLD imperative engine's -// startService only special-cased Provider != nil for the ZERO-containers -// case, so an existing non-relay container was always started normally, +// provider-backed) must still be started -- the start path only +// special-cases Provider != nil for the ZERO-containers case, so an +// existing non-relay container is always started normally, // including post_start hooks. planProviderRelayRestart's first version // silently dropped this case (filtered on isRelayContainer), returning // success without starting anything. @@ -1033,8 +1033,8 @@ func TestPlanStart_ProviderPathEnrichesStaleNonRelayContainer(t *testing.T) { assert.Assert(t, start.Operation.Service != nil, "a stale non-relay container must get an enriched start (injection applies), like any ordinary replica:\n%s", plan) // A Copilot review finding on the post_start-only version of this fix: - // pre_start must also run for this path, exactly like the imperative - // engine's own lowestNumberedContainer(toStart) + isRelayContainer gate. + // pre_start must also run for this path, gated on the lowest-numbered + // container to start and on isRelayContainer. if pre == nil { t.Fatalf("expected pre_start hooks to run before starting prov's stale non-relay container:\n%s", plan) } @@ -1094,8 +1094,8 @@ func TestPlanStart_ProviderPathChainsMultipleStaleContainers(t *testing.T) { // replica a relay?" on every iteration of the chain-building loop, so when // the lowest-numbered stale container was the relay (skipped) but a later // one was an ordinary stale replica, pre_start still ran against that later -// one. The old imperative engine's own gate -- lowestNumberedContainer(toStart) -// checked once -- never falls through like that: if its single candidate is +// one. The gate -- the lowest-numbered container to start, checked once -- +// never falls through like that: if its single candidate is // a relay, pre_start is skipped entirely for the whole chain, with no // fallback to a later non-relay container. func TestPlanStart_ProviderPathPreStartGateDoesNotRetryPastARelay(t *testing.T) { diff --git a/pkg/compose/reconcile_test.go b/pkg/compose/reconcile_test.go index 5e2e00f406..b70184cfac 100644 --- a/pkg/compose/reconcile_test.go +++ b/pkg/compose/reconcile_test.go @@ -1336,9 +1336,8 @@ func TestReconcileContainers_ExitedIsNoop(t *testing.T) { // depends_on could start before their dependencies' operations complete. // Stale pre_start hook runners (left by a previous run that failed before // removing them) are purged by the plan when pre_start is going to run again: -// hooks declared and no replica running at observation — the imperative -// gating. Removals are best-effort and drop the runner's anonymous volumes, -// like the warn-only imperative purge they mirror. +// hooks declared and no replica running at observation. Removals are +// best-effort (warn-only) and drop the runner's anonymous volumes. func TestReconcileContainers_StaleHookRunnersPurged(t *testing.T) { project := &types.Project{ Name: "myproject", diff --git a/pkg/compose/restart_test.go b/pkg/compose/restart_test.go index 101d8df22b..f74dd8f6ed 100644 --- a/pkg/compose/restart_test.go +++ b/pkg/compose/restart_test.go @@ -33,11 +33,11 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -// These tests characterize the restart path before the lifecycle engines -// converge (#14081): which depends_on relations survive project preparation, -// and the per-container sequence (pre_stop → ContainerRestart → post_start) -// with its events. restart stays on the imperative engine but shares the -// hook/wait primitives with the plan. +// These tests characterize the restart path: which depends_on relations +// survive project preparation, and the per-container sequence (pre_stop → +// ContainerRestart → post_start) with its events. restart still walks the +// dependency order itself (InDependencyOrder) but shares the hook/wait +// primitives with the plan. // TestPrepareRestartProject locks the selection semantics: only depends_on // relations declaring restart:true are kept, and naming services includes diff --git a/pkg/compose/service_containers.go b/pkg/compose/service_containers.go index 3010658ff0..3a9a84dc1b 100644 --- a/pkg/compose/service_containers.go +++ b/pkg/compose/service_containers.go @@ -38,12 +38,13 @@ import ( "github.com/docker/compose/v5/pkg/api" ) -// This file gathers the per-service container helpers shared by both -// lifecycle engines: the plan-based reconciler (reconcile.go, entered through -// create/up) and the imperative dependency-ordered engine (dependencies.go, -// used by start/stop/restart/down). It covers container naming, resolution of -// service references (volumes_from, network_mode/ipc/pid, links), dependency -// waiting, container creation through the Docker API, and service startup. +// This file gathers the per-service container helpers shared by the +// plan-based engine (reconcile.go and the executor, entered through +// create/up/start) and by the commands that still walk the dependency order +// themselves (dependencies.go: restart, stop, down and the classic build). It +// covers container naming, resolution of service references (volumes_from, +// network_mode/ipc/pid, links), dependency waiting and container creation +// through the Docker API. const ( doubledContainerNameWarning = "WARNING: The %q service is using the custom container name %q. " + @@ -266,7 +267,7 @@ func (s *composeService) waitDependencyReached(ctx, origCtx context.Context, dep // Every condition this switch doesn't handle explicitly ends up // here: ServiceConditionStarted is filtered out before this // function is ever called (shouldWaitForDependency — "already - // managed by InDependencyOrder"), so nothing reaching this + // ensured by the dependency order"), so nothing reaching this // branch is ever a value this runtime actually understands. // That covers a compose file with an unrecognized condition // (rejected by schema.Validate before it can even get this far @@ -371,16 +372,15 @@ func (s *composeService) checkDependencyCompleted(ctx context.Context, dep strin return false, errors.New(msg) } -// errNoContainerToStart is shared by the imperative engine (startService) -// and the plan reconciler (planServiceStart) so the message cannot drift — -// the plan's fidelity contract is word for word. +// errNoContainerToStart is the failure of a service the start phase finds no +// container to start for (see planServiceStart). func errNoContainerToStart(service string) error { return fmt.Errorf("service %q has no container to start", service) } func shouldWaitForDependency(serviceName string, dependencyConfig types.ServiceDependency, project *types.Project) (bool, error) { if dependencyConfig.Condition == types.ServiceConditionStarted { - // already managed by InDependencyOrder + // already ensured by the dependency order return false, nil } if service, err := project.GetService(serviceName); err != nil { @@ -440,17 +440,23 @@ func (s *composeService) createContainer(ctx context.Context, project *types.Pro return ctr, nil } -// startMx serializes ContainerStart calls across the whole process: the -// engine allocates published ports from ranges non-atomically, and two -// concurrent starts can be assigned the same port. Every code path calling -// ContainerStart on a service container must hold it (the plan executor's -// execStartContainer and the imperative startServiceContainer both do). +// startMx serializes the plan executor's ContainerStart calls across the whole +// process. It is a workaround for docker/compose#12846 (added by #12851): with +// Engine 28.0 to 28.2, containers started concurrently could be assigned the +// same published host port, a flaw of the daemon's port allocator. That flaw +// is fixed since Engine 28.3.0 (moby/moby#50054), so beyond it the mutex serves +// no purpose, and it is unclear it ever prevented the duplicates reported. +// Only this path takes it: restart, run, the relay and the hook runners start +// their containers without it. // -// It deliberately stays a package-level variable rather than a composeService -// field: the race lives in the daemon, not in a service instance, so two -// instances (e.g. SDK users) driving the same daemon must still exclude each -// other. A per-instance lock would silently reintroduce the duplicate-port -// assignment between them. +// TODO: remove startMx, and the locking around it, once Engine 28.x is out of +// support. +// +// While it exists, it deliberately stays a package-level variable rather than +// a composeService field: the race lives in the daemon, not in a service +// instance, so two instances (e.g. SDK users) driving the same daemon must +// still exclude each other. A per-instance lock would silently reintroduce the +// duplicate-port assignment between them. var startMx sync.Mutex func (s *composeService) createMobyContainer(ctx context.Context, project *types.Project, service types.ServiceConfig, diff --git a/pkg/compose/start.go b/pkg/compose/start.go index b454169475..e67f46493f 100644 --- a/pkg/compose/start.go +++ b/pkg/compose/start.go @@ -33,13 +33,10 @@ func (s *composeService) Start(ctx context.Context, projectName string, options }, "start", s.events) } -// start builds and executes a Start-only plan (epic #14081, lot 2): the -// semantic switchover from InDependencyOrder's imperative traversal -// (startService/waitDependencies) to the same reconciler/executor pair every -// other lifecycle command already uses. ScopeStart's planStartPhase works -// straight off the observed containers (see TestPlanStart_StartOnlyScope) -- -// it never converges anything, matching start()'s own historical contract of -// starting what exists, never creating. The Start phase of an interactive up +// start builds and executes a Start-only plan. ScopeStart's planStartPhase +// works straight off the observed containers (see +// TestPlanStart_StartOnlyScope) -- it never converges anything: start() +// starts what exists, never creating. The Start phase of an interactive up // does not go through here: it runs from the up's own plan, and hands its // session's listener to runStart (see runInteractiveUp). func (s *composeService) start(ctx context.Context, projectName string, options api.StartOptions) error { @@ -83,19 +80,15 @@ func (s *composeService) start(ctx context.Context, projectName string, options // Unlike upDetached/create, start() never calls emitRunningEvents: a // no-op `compose start` (every container already running) must stay - // silent, matching the imperative engine's own contract (see the now - // deleted TestStartService_AlreadyRunningIsSilent) -- a Copilot review - // finding on the first version of this migration, which had copied - // upDetached's call without checking start()'s narrower UX contract. - // emitRunningEvents is create-oriented: it exists so `up`/`create`'s full + // silent. emitRunningEvents is create-oriented: it exists so + // `up`/`create`'s full // project-status display accounts for containers the plan won't touch, // a concern start() has never had. - // start()'s own dependency waits used to thread WaitTimeout through every - // wait unconditionally (InDependencyOrder → startService, regardless of - // options.Wait -- --wait-timeout alone, with no --wait, is a legal CLI - // combination nothing rejects). exec.waitTimeout reproduces that for the - // plan's own OpWaitCondition nodes, each with its own fresh window + // WaitTimeout bounds every dependency wait of start() unconditionally, + // regardless of options.Wait (--wait-timeout alone, with no --wait, is a + // legal CLI combination nothing rejects). exec.waitTimeout applies it to + // the plan's own OpWaitCondition nodes, each with its own fresh window // starting when that wait begins -- see upDetached's identical reasoning. exec := s.newPlanExecutor(project, observed) exec.waitTimeout = options.WaitTimeout diff --git a/pkg/compose/up.go b/pkg/compose/up.go index 53695f9c3c..dc07f85548 100644 --- a/pkg/compose/up.go +++ b/pkg/compose/up.go @@ -113,18 +113,15 @@ func (s *composeService) prepareUp(ctx context.Context, project *types.Project, // The plan's own OpWaitCondition nodes (service_healthy / // service_completed_successfully dependency waits) block with no timeout - // of their own -- unlike start()'s per-dependency waitDependencies call, - // which already threads WaitTimeout through every wait unconditionally - // (InDependencyOrder passes it to startService regardless of - // options.Wait -- --wait-timeout alone, with no --wait, is a legal CLI - // combination nothing rejects). exec.waitTimeout bounds exactly those - // nodes (see executeNode), each with its OWN fresh window starting when - // that wait begins -- not a single shared budget counted down from + // of their own: WaitTimeout bounds every dependency wait unconditionally, + // regardless of options.Wait (--wait-timeout alone, with no --wait, is a + // legal CLI combination nothing rejects). exec.waitTimeout bounds exactly + // those nodes (see executeNode), each with its OWN fresh window starting + // when that wait begins -- not a single shared budget counted down from // before the plan even started. Sharing one absolute deadline across // create/start/hook work and every wait would let slow, unrelated work // alone exhaust it, failing an otherwise healthy deployment as "not - // healthy" with no wait ever actually timing out -- start()'s real, - // per-wait-independent behavior never had that failure mode. + // healthy" with no wait ever actually timing out. exec := s.newPlanExecutor(project, observed) exec.waitTimeout = options.Start.WaitTimeout return project, exec, plan, nil