Commit Graph
6113 Commits
Author SHA1 Message Date
Nicolas De Loof 6b419bc7a1 fix(hash): repair the tests after #14093 and #14215 merged in reverse order
The plan documented in both PRs had #14215 land first, then #14093's
compose-go bump delete the transient TestServiceHashContinuity. Merged
in the opposite order, main's pkg/compose tests no longer even compile:
richServiceFixture sets fields the container-spec layering turned into
promoted embeds (struct literals require go1.27 for that), and once
compilable two tests fail by construction.

- richServiceFixture is built by assignment;
- TestServiceHashContinuity is deleted, exactly as its own comment
  prescribes: the layered compose-go is the reorder it existed to
  outlive, its proof duty (pinned == historical bytes) is done, and
  TestHashGoldenValues carries the continuity contract alone — it
  PASSES on the layered compose-go, confirming the pinning preserved
  every historical hash through the layering;
- pull_refresh_after, newly exposed at the service root, is appended
  to serviceHashKeyOrder: the fallback already emitted it in that
  exact position, so no hash moves — listing it only freezes the
  layout and satisfies the coverage walker.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-22 11:03:06 +02:00
Nicolas De Loof 0c68d08f26 refactor(hash): review nits — shared helpers, hardened reflection walker
The trimming of hash-excluded fields becomes trimServiceHashFields,
shared between ServiceHash and the continuity test so the exclusion set
cannot silently drift; pinRootKeyOrder reuses the package's sortedKeys
helper; the key-order coverage walker now follows encoding/json
promotion rules for non-struct embeds and unexported fields.
Golden hashes unchanged.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-22 09:49:51 +02:00
Nicolas De LoofandClaude Fable 5 725fe535c0 fix(hash): pin the service config-hash byte layout to its historical form
ServiceHash digests json.Marshal of types.ServiceConfig, which couples
every recorded config-hash to the DECLARATION ORDER of compose-go
struct fields: encoding/json emits struct fields in that order and
flattens embedded structs at their embedding position. Any compose-go
refactoring that moves a field — such as the upcoming container-spec
layering, which regroups the whole struct — would change the bytes, and
with them the hash, of configurations that did not change at all: every
container recreated on the first `up` after an upgrade.

The hash now re-emits the marshaled object with its ROOT keys in a
frozen list reproducing the historical order (generated by reflection
over the last pre-layering compose-go), values byte-verbatim. For
today, the output is byte-identical to the direct marshal — proven by a
continuity test — so every existing container stamp stays valid: no
migration, no recreation, full backward compatibility. From the first
struct reorder on, the frozen list alone carries that continuity,
locked by golden-value tests; a root attribute added later is appended
in sorted order and, thanks to omitempty, only moves the hash of
configurations that use it — exactly like a field addition always did.
A reflection test fails when compose-go grows a root attribute missing
from the list, so extending the hash surface stays a reviewed decision.

Nested objects keep their own struct marshal: a reorder inside one of
them would still move hashes — the golden tests exist to turn that into
a caught, reviewed event rather than a silent side effect. Network and
volume hashes are unchanged (their structs are not being reordered) and
gain the same golden locks.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-22 09:49:51 +02:00
dependabot[bot] 9343ab8c98 build(deps): bump github.com/mattn/go-shellwords from 1.0.14 to 1.0.15
Bumps [github.com/mattn/go-shellwords](https://github.com/mattn/go-shellwords) from 1.0.14 to 1.0.15.
- [Release notes](https://github.com/mattn/go-shellwords/releases)
- [Commits](https://github.com/mattn/go-shellwords/compare/v1.0.14...v1.0.15)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-shellwords
  dependency-version: 1.0.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-22 09:32:24 +02:00
Nicolas De Loof 795a4159e0 test(cmd/compose): update TestResolveImageDigests for compose-go#929
compose-go's WithImagesResolved no longer special-cases a type=image
volume source that happens to match another service's name -- per
compose-spec/compose-go#929, such a source is always resolved as a
plain docker image reference, never treated as an implicit reference
to that service's locally built image. This surfaced as a real CI
failure after bumping the compose-go pin: the mock in
TestResolveImageDigests only expected calls for nginx/someimage/
hookimage and panicked on the now-unexpected lookup for "builder".

Update the test to expect the volume source to resolve like any other
image reference, and drop the stale comment in resolveServiceImageDigests
describing the old, now-removed behavior.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 18:01:16 +02:00
Nicolas De Loof 26c994d454 build(deps): bump compose-go to the merged #14223/#931 fix
compose-spec/compose-go#931 is merged: TestBuildAdditionalContextDisabledService,
red until now by design, passes for real against the released compose-go pin.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 18:01:16 +02:00
Nicolas De Loof 379babe8dc test(e2e): force --build so a stale image cannot skip the tested path
docker-agent review: without --build, an image tagged
e2e-additional-context-base:latest left over from a previous run would
let up skip the build entirely, passing the test without exercising
the additional_contexts-to-a-disabled-service path it exists to lock
in.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 18:01:16 +02:00
Nicolas De Loof 7e23119934 test(e2e): reproduce #14223 -- additional_contexts to a disabled service
A service reachable only as another service's build additional_contexts
must still build even when its own profile is inactive: it's referenced
for its image, not started as a workload -- the same tolerance the
depends_on consistency check already grants a disabled, non-required
dependency, and the same case addBuildDependencies already special-cases
on the docker/compose side.

compose-go's own consistency check doesn't grant that tolerance yet: it
rejects the reference outright, failing the whole project load before
addBuildDependencies (or anything else, up included) ever runs -- for
up, build and up --no-build alike, exactly as reported.

This is expected to fail until compose-go bumps to include
compose-spec/compose-go#931.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 18:01:16 +02:00
Nicolas De Loof 0d206e6704 fix(compose): move the job-target refusal into projectOrName itself
The previous fix only covered create/start. projectOrName backs every
other service-targeting command too (stop, kill, pause, unpause, logs,
rm, down, ps, events), so they still surfaced the same two problems: a
raw "no such service" error for a job name, and -- when
COMPOSE_PROJECT_NAME is set -- a silent no-op, since projectOrName's
label-driven fallback swallows the load error entirely.

Fold the check into projectOrName so every caller is fixed at once,
instead of duplicating the wiring per command. start.go's own
job-handling code, now redundant, is removed; jobTargetErr and
unselectedJobs (cmd/compose/run.go) are unchanged, just called from one
more place.

Adds unit coverage for projectOrName itself (including the
COMPOSE_PROJECT_NAME path and the typo-correlation edge case) and e2e
coverage for two more callers (stop, down) to confirm the fix lives in
the shared helper rather than being re-implemented per command.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof 039413cc77 fix(start): still refuse a job when COMPOSE_PROJECT_NAME masks the error
projectOrName falls back to a label-driven, file-less project on any
load failure when COMPOSE_PROJECT_NAME is set -- swallowing the load
error entirely, including "no such service" for a target that's
actually a declared job. Since that branch returns err == nil,
jobTargetErr (gated on err != nil) and rejectScheduledJobs (gated on
project != nil) were both skipped: `docker compose start migrate`
fell through to a label-driven start that finds no container for a
job that was never run, and exited 0 having silently done nothing.

Refuse a named job explicitly on that fallback path too, reusing
unselectedJobs from the previous commit.

Also adds the test coverage this and the previous fix (job-target
error messages on create/start) were entirely missing: create.go and
start.go had no unit tests at all, and pkg/e2e/jobs_test.go only
exercised up/run.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof 48b013ad13 fix(run): correlate jobTargetErr against the specific failing name
jobTargetErr was called with the full [SERVICE...] argument list, but
compose-go's selection error only ever names the one target it
actually failed on. The loop matched any name in the list against the
job set, so `docker compose create totally-bogus migrate` (a real
typo plus a legitimate job) reported the job error instead of the
real "no such service: totally-bogus", masking the typo.

Match the name the error actually names, not any job name that
happens to share the invocation. unselectedJobs is extracted out of
jobTargetErr so start's own project-resolution fallback (next commit)
can reuse the same unselected-reload logic.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof 56bc854f37 fix(run): manual: false also blocks a job pulled in transitively
materializeJobClosure walked job-typed depends_on edges without
re-checking triggers.manual: false on anything but the top-level run
target, so `docker compose run A` where A depends_on job B (manual:
false) silently ran B anyway.

manual: false declares a job harmful to trigger outside its
schedule; depends_on doesn't change who caused the execution or when
-- pulling B in to satisfy A's dependency is still the run command
causing that out-of-schedule execution, one hop removed. Refuse it
the same way the top-level check already does, before anything in the
closure is created.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof f07bec20cf fix(create, start): clear error when targeting a job by name
docker compose create <job> / start <job> failed with the raw
compose-go error "no such service: <name>", since project loading
rejects the job name before rejectScheduledJobs or any job-aware
logic ever runs -- unlike run, neither command knows how to detect
that a name is a declared job. The result gave no hint that jobs
exist or that run is the right command.

Jobs stay run-only by design; this only replaces the error message
when the target is a declared job, via a small helper (jobTargetErr)
shared by both commands, reloading unselected exactly like run's own
job-detection fallback. Any other "no such service" (a real typo)
keeps the original, precise error.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof c8596c67fe fix(config): scan jobs in --images, --lock-image-digests, --resolve-image-digests
Only the interpolated path (imagesOnly) and pkg/compose/publish.go
walked project.Jobs the same way they walk project.Services. Three
sibling paths in config.go were missed:

- runConfigImages (--images) only printed service images.
- lockModel (--no-interpolate --lock-image-digests) dropped the
  top-level "jobs" key entirely along with every other non-services
  key, instead of filtering it down to image/type:image-volumes like
  services.
- resolveImageDigests (--no-interpolate --resolve-image-digests) only
  built its pseudo-project from model["services"], so job images and
  type: image volume sources never resolved -- confirmed by a new test
  that failed against the first attempt at this fix (job image stayed
  unpinned) until dressed as its own pseudo-project the same way
  generateImageDigestsOverride in publish.go already does, since
  WithImagesResolved only walks a project's Services, never its Jobs.

The extra complexity that last one picked up is split into
resolveServiceImageDigests (services, unchanged from before this
commit) and resolveJobImageDigests (new), called from a small
dispatcher.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof eedf9131b4 fix(run): stop double-warning unsupported attributes on the job fallback
The fallback reload runProject uses to detect a job target passed
warnUnsupportedAttributes, the same option as the first, selective
load right before it -- which had already fired every warning once.
For the common case where the target is neither a service nor a job
(a plain typo), every finding got logged twice before falling through
to the original, correct error anyway.

skipUnsupportedAttributesWarning already exists for exactly this
secondary-load case (see its other call sites in compose.go and
completion.go); use it here too.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof f9cfe76a59 fix(compose): adapt relay/start tests to the container-spec layering
Rebasing onto main pulled in the relay work (added after this branch
was cut), whose tests build ServiceConfig/PreStart literals against
the flat, pre-layering shape: DependsOn/Networks set directly, and
PreStart typed as []ServiceHook. Both are incompatible with the
ContainerSpec/WorkloadSpec split this branch adopts -- the promoted
DependsOn/Networks fields need go1.27 to set via a flat literal (this
module stays on 1.26.3), and PreStart is now []PreStartHook, a full
container specification, not a plain hook.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 18fdf9832a publish, config --images: jobs are scanned and pinned like services
Every publish safeguard and image-pinning path used to walk only
project.Services, leaving jobs invisible:

- the sensitive-data checks (literal environment values, env_file
  scans, bind-mount warnings, build-only rejection) now cover jobs — a
  job declaring AWS_SECRET_ACCESS_KEY=... was published without the
  guard rail a service gets;
- the image-digest override pins job images too (jobs dressed as
  services run through the exact WithImagesResolved semantics), so the
  published artifact is reproducible for jobs as well;
- the application index references job images;
- `config --images` lists them.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 80cae9a7a5 pre_start: consume the load-time-resolved hook specification
compose-go resolves each hook against its service at load time: the
model itself carries the full container specification a hook runs with,
and the runtime consumes it as-is through the standard create path —
the runtime-side merge helper goes away.

Consuming the full spec means honoring ALL of it:
- service references it may carry — volumes_from entries, and
  service:-scoped network_mode/ipc/pid, inherited or declared — resolve
  to live container IDs exactly like the service create path does; the
  daemon knows nothing about service names and rejected them, failing
  up for any service combining volumes_from with a pre_start hook
  (locked by an e2e scenario).
- hook labels — declared or inherited — merge into the container's
  labels, the runtime identification set winning on conflicts.
- the <API 1.44 network-connect fallback joins the HOOK's networks,
  not the parent service's: a hook overriding networks was connected
  to the wrong ones.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof 2804f9d4ae run: honor the explicit manual: false opt-out on jobs
Per the spec, any job can be triggered manually by an explicit run
command, its automated triggers notwithstanding — so run now accepts
scheduled jobs too. The exception is a job explicitly declared with
triggers.manual: false, which run rejects: meant for scheduled jobs
whose out-of-schedule execution would be harmful.

compose-go is bumped to the jobs-branch commit making Manual tri-state
(*bool) and allowing manual and schedule to be combined.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 135fb0ed3a e2e: jobs scenarios — scheduled rejection, manual run, env_file, job chain
Scenario-DSL coverage for the jobs entry points:
- up refuses a project declaring an active scheduled job before creating
  any resource;
- run executes a manual job like a service, starting its depends_on
  services first, and refuses a schedule-only job;
- a job's own env_file feeds its environment through run — the
  materialization happens before environment resolution;
- a job depending on another job runs the dependency to completion
  first, through the exact machinery a service dependency uses.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De Loof ef277d266b pre_start: inherit the whole container specification generically
pre_start hooks carry the full container specification, but the
init-container runner only consumed a handful of attributes (image,
command, user, env, workdir) — everything else was silently dropped.

Instead of wiring attributes one by one, the hook's specification is
merged over the service's ContainerSpec through compose-go's own file
merge machinery (override.Merge on the canonical yaml tree): command
and entrypoint replace, environment merges per key with the hook
winning, extra_hosts and dns accumulate entries, ulimits merge — the
exact per-attribute rules of multi-file compose, maintained in one
place. Every ContainerSpec attribute inherits this way, current and
future, with zero attribute-specific code.

The merged spec then runs through the standard create path
(getCreateConfigs) as a service-shaped one-off, so resources,
capabilities, dns, sysctls, logging... materialize exactly as they
would for a service container. Hook containers keep their minimal
labels and carry no container-number, so tooling telling replicas
apart does not count them.

The only deliberate exception is volumes: mounts inherit at runtime
through volumes_from — the only mechanism that shares the service's
anonymous and image volumes — and the hook's own volume declarations,
materialized by the standard path, take precedence per target. This is
what lets an init container get read-write access to a volume the
service mounts read-only (fixes: see PR).

e2e scenarios lock extra_hosts inheritance and accumulation, volume
override and completion, and the unit tests pin the merge rules.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 eb40156352 run: execute jobs exactly like services
`docker compose run <job>` materializes the job — a ContainerSpec +
WorkloadSpec, the same layers a service is made of — as a service for
the one-off machinery: its profile is activated, the project narrowed
to its dependencies by WithSelectedJob, and the exit code flows back.

The materialization happens during project loading, BEFORE service
selection and environment resolution: the job's own env_file and
label_file resolve exactly like a selected service's would, unrelated
services' env_file still don't need to exist, and a target that is
neither a service nor a job keeps the precise selection error (the
retry is keyed on "the target is a declared job", never on matching an
error message). A service and a job may share a name: the service wins,
matching what the selector resolved.

A job may depend on other jobs: the whole job closure materializes, so
every depends_on edge resolves to a runnable service satisfying its
declared condition — a dependency job runs to completion through the
exact machinery a service dependency does — instead of dangling as an
unresolvable name. Job extensions (x-*) survive materialization.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 124cbb8eb9 jobs: warn and ignore, reject active scheduled jobs on up, create and start
The model may declare jobs this runtime cannot execute yet. Commands
that materialize the application warn about them, and refuse an active
(profile-enabled) scheduled job before any resource is created — up,
create and start alike: silently not scheduling would break
expectations, while manual jobs just wait for an explicit trigger.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Fable 5 e96ca020f2 build(deps): adopt compose-go jobs and the container-spec layering
compose-spec/compose-go#866 is merged: jobs top-level element, container
specification layered as ContainerSpec/WorkloadSpec, pre_start hooks
carrying the full container specification resolved at load time. Bump
to the merged head and adapt in the same movement — composite literals
setting moved fields wrap them into the embedded ContainerSpec /
WorkloadSpec (promoted field access was already source-compatible, so
this is literal-only, no behavior change), and pre_start handling is
typed against PreStartHook.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-21 16:35:20 +02:00
Nicolas De LoofandClaude Sonnet 5 95929928c1 fix(provider): drop the relay's capabilities to the strict minimum
The relay only dials an upstream and forwards bytes: it needs none of
Docker's default Linux capabilities. Run it with CapDrop: ["ALL"],
keeping only NET_BIND_SERVICE back via CapAdd since a route commonly
targets a privileged port (e.g. 80, 443) that the relay -- running
unprivileged as UID 65532 -- must still be able to listen on inside
its own container.

Validated end-to-end against a real provider project: a container on
the relay's network reaches the provider through its published, low
port with the hardened capability set in place.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 17:51:23 +02:00
Nicolas De LoofandClaude Sonnet 5 bb22a260e8 fix(provider): guard down's stopContainer against the relay too
glours' review flagged the same bug class this PR already fixes for
start/restart: stopContainer (down.go) runs PreStop hooks
unconditionally, and runHook execs into the container -- which fails
against a shell-less relay. Guarded it the same way, with a regression
test.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 17:51:23 +02:00
Nicolas De LoofandClaude Sonnet 5 b06424e0b9 fix(provider): guard pre_start against the relay too, address review nits
docker-agent review (via glours) flagged that startService's own
pre_start call site had the same gap this PR fixes for start/restart:
lowestNumberedContainer can resolve to the relay container, and
runPreStart shares its VolumesFrom with whatever container it is
given -- reachable via plain start/up whenever a provider service also
declares pre_start. Guarded it the same way, with a regression test.

Also, while touching this: mirror the TOCTOU/StateRemoving tolerance
already needed in removeServiceRelay onto ensureServiceRelay own
removal branch, which had the identical latent race; dedupe
isRelayContainer against the pre-existing isRelay(labels) in
monitor.go instead of reimplementing the same label check; and collapse
runPlugin two separate command == "up" checks into one.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 17:51:23 +02:00
Nicolas De LoofandClaude Sonnet 5 6854503bf7 fix(provider): make removeServiceRelay idempotent under concurrent removal
docker-agent review: removeServiceRelay called ContainerRemove
unconditionally, unlike the existing ensureServiceRelay which already
guards against two concurrency issues — a relay stuck in StateRemoving
(a concurrent ContainerRemove fails with "removal already in
progress") and a TOCTOU window between findRelayContainer and
ContainerRemove where another goroutine removes the relay first. Both
now degrade to the already-achieved outcome (relay gone) instead of
failing the whole up.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 17:51:23 +02:00
Nicolas De LoofandClaude Sonnet 5 b5cf514b29 fix(provider): clean up stale relay and guard start/restart against it
A provider that stops publishing endpoints on a later `up` left its
previously created relay running: deployRelay was only evaluated when
endpoints were non-empty, so a zero-endpoint up fell through with no
cleanup. The generic reconciler is no help either — it returns early
for any service.Provider != nil, before the recreate logic that would
otherwise catch this. runPlugin now removes any existing relay when a
provider publishes no endpoint on an up, whether it never did or
stopped doing so since a previous run.

Separately, startServiceContainer and restartContainer had no
relay-awareness at all, unlike exec/cp which already refuse to target
one via checkRelayTarget. A relay is a shell-less, FROM-scratch binary
standing in for the service on the network: it has no filesystem or
process for secrets/configs injection or PreStart/PostStart/PreStop
hooks to act on. Nothing in the compose-spec schema forbids declaring
secrets, configs or hooks on a provider: service, so this was reachable
in practice, not just in theory. Both now skip those steps for a relay
container while still starting/restarting the container itself
normally.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 17:51:23 +02:00
Nicolas De Loof 34d0f70184 fix(relay): tolerate a concurrent network connect race
ensureServiceRelay runs concurrently per provider service (the shared
project mutex is released before this Docker API work). Two goroutines
reconciling relays that happen to share a consumer network can both
decide, from their own ContainerList snapshot, that the same relay
still needs connecting to it. Whichever runs second got the daemon's
"endpoint already exists" back from NetworkConnect, wrapped and
returned as a fatal error even though the relay was already in the
desired state.

Tolerate errdefs.IsConflict the same way the rest of the relay/network
reconciliation already does, instead of failing the run over a race
that already resolved to the right outcome.

(docker-agent review on #14236)

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 09:51:49 +02:00
Nicolas De Loof a6b4ee26c7 fix(relay): reconnect to new networks when routes are unchanged
relayIdentity only hashes image+routes, never network topology. When
a dependent service is added after the relay is already up, on a
network the relay isn't attached to, the relay's identity is
unaffected -- so ensureServiceRelay's running-container fast path
returned nil without ever looking at whether networkKeys had grown.
The new consumer could never reach the provider through it, silently:
no error, just an unreachable compose-native address.

ensureRelayNetworks now runs on every reuse path (running, stopped,
paused) before the state-specific branch decides what to do with the
container, connecting whichever networks in networkKeys the relay
isn't already on -- reusing NetworkSettings from the same
ContainerList call findRelayContainer already made, no extra inspect.
Networks the relay is already connected to are left untouched.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 09:51:49 +02:00
Nicolas De Loof 7c2c393d74 ci: lint, vet and test relay/ as its own module
relay/ (github.com/docker/compose-relay) is a separate go.mod: root
`go vet`/`golangci-lint`/`go test` never covered it, and the local
pre-commit hook explicitly excludes any directory with its own go.mod
for the same reason. Only the image build (relay-image-cross) was ever
validated in CI -- the Go source itself had no quality gate.

Adds relay-lint and relay-test bake targets, mirroring the root lint/
test Dockerfile stages: a build-base downloading go.mod's (currently
empty) dependency set, a lint stage running the same pinned
golangci-lint version as the root, and a test stage running go vet
then go test. relay-lint joins the "validate" bake group; both are new
matrix entries in the CI validate job.

relay/ gets its own .golangci.yml (the lint stage's build context is
"./relay" only, so it can't see the root config) with the same rule
set as root, minus what doesn't apply to a dependency-free module
(the e2e build tag, testify/gotest.tools-specific depguard entries).

Turning lint on for the first time surfaced 8 real findings in
relay/main.go and main_test.go (unchecked Close() errors, %v instead
of %w on a wrapped error, a redundant embedded-field selector, and
log.Fatalf after a defer that would never run) -- fixed here so the
new job is green from the start, not merged red.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-17 09:23:54 +02:00
Nicolas De LoofandClaude Sonnet 5 8d266c871d fix(provider): stop reaches the relay container like down already does
`compose stop` on a provider service short-circuited straight into
runPlugin(..., "stop"), never touching stopContainers -- so the relay
container this PR gives a provider service (RestartPolicyUnlessStopped,
a real persistent container) was left running and still Up in
`compose ps`, contradicting docs/extension.md's claim that stop
treats the relay like the service.

downService already stops/removes a provider's containers before
invoking the plugin; stop.go gets the same treatment: the service's
containers are stopped unconditionally, then the plugin's own stop
hook (if any) runs for provider services. start/restart needed no
change -- neither special-cases providers, so they already operate on
whatever containers match the service filter, relay included.

Verified locally: reverting just this change makes the new e2e steps
below fail on "stop halts the relay container".

Extends TestProviderPublishEndpoint with stop/start/restart coverage
of the relay container.

(glours review on #14193)

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De Loof a86f6eb7af docs: dedicated section for compose-native addressing via publish-endpoint
The 'Connection to a service' section only covered environment-variable
injection, while the relay mechanism was buried in the message-type
reference list. Move the relay narrative into its own subsection there,
illustrated with a sequence diagram showing the full path — the provider
publishing the endpoint, Compose deploying the relay on the dependents'
networks, and the application connecting at the compose-native address.
The publish-endpoint entry in the message list keeps the wire-format
contract and points to the new section.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 4b6f6022a2 ci: build and publish docker/compose-relay
The relay image gets the same pipeline as docker/compose-bin, through
the shared github-builder bake workflow:

- merge.yml publishes docker/compose-relay on every push — `edge` from
  main, and the `v1` tag consumers actually pull only on compose
  release tags: v1 is the image CONTRACT major, not a compose version,
  and moving it only at releases keeps what users pull predictable;
- ci.yml validates the cross build on every PR (push: false), like
  bin-image-test does;
- the bake file splits relay-image (local/dev build, default tag) from
  relay-image-cross (CI target: meta-helper-driven tags, every linux
  platform the compose binary ships for — the relay runs as a container
  on the engine, so darwin/windows make no sense for it).

The docker/compose-relay Docker Hub repository itself must exist and
accept pushes from the org registry identity — a one-time maintainer
step, called out in the PR description.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 de2148f09e fix(relay): reap idle half-open forwards instead of pinning them forever
A peer that never closes after receiving the relayed FIN used to pin
the forward's goroutine pair and both TCP connections until SIGKILL —
and with them the drain in main. Force-closing both ends as soon as one
direction finishes would have thrown out TCP half-close (a client that
FINs its request and then reads a long response), so the surviving
direction now runs under an idle grace instead: a read deadline
re-armed before every Read once the other direction is done. Active
streams are never cut — only pairs sitting idle past the grace are
reaped. Locked by two tests: the silent-peer pair is reaped at the
grace, and a response still streaming after the client's half-close
survives well past it.

The demo provider's comment also spells out why serve-demo is neither
Wait()ed nor a zombie: the provider exits within seconds, so init has
long adopted — and reaps — the subprocess when its three minutes are up.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 60cf6fc02f docs(provider): document the demo endpoint subprocess deliberate lifetime
The serve-demo subprocess is the demo's provisioned resource: it must
outlive the provider invocation — consumers reach it through the relay
after up returns — and it reaps itself after three minutes. Spell that
out where a reader (or reviewer) would otherwise expect a Kill/Wait.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 39193657eb fix(provider): deploy the relay outside the shared project mutex
runPlugin held the global mutex — there to guard concurrent writes to
project.Services — across ensureServiceRelay, whose Docker API work
(list, create, image pull, start, a 30s removal wait) forced every
concurrent provider to wait on the slowest one.

The mutex now covers only the shared-state work: env-var injection plus
the relay's network selection, which reads project.Services and must
not race with another provider's writes. The relay is deployed after
the lock is released, taking the pre-computed network keys — simply
unlocking around the call, as first suggested, would have traded the
serialization for a data race on the services map.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 29cb687b7a fix(provider): down removes the relay with the provider service
down handled a provider service by running the plugin alone — but the
service may own a project container, the relay deployed when it
published endpoints. Left running, it kept the project network in use
and `down -v` failed with "Resource is still in use".

The relay is now part of the service's deprovisioning: its containers
are removed first, then the plugin removes the provider's resource —
mirroring up, which provisions the resource before deploying the relay.

The example provider's down used to answer with a hardcoded error (a
leftover no test relied on): it now succeeds, with the failure
simulation kept behind PROVIDER_DOWN_FAILURE.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 a4b443f453 fix(provider): call an invalid provider message a message, not a response
Providers push messages on their own initiative — setenv, info,
publish-endpoint… — they do not respond to anything. "invalid response
from plugin" sent users looking for a request that never existed.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De LoofandClaude Fable 5 fc72c165aa fix(provider): relay rewrites host-relative upstreams to host.docker.internal
A provider legitimately expresses its endpoints from the host's
perspective — "localhost:5734" is where its resource listens, on the
machine compose runs on. But the relay dials from its own network
namespace, where loopback names the relay container itself: routes were
passed verbatim, so every connection died on the relay's own empty
loopback while the host.docker.internal ExtraHosts mapping provisioned
for exactly this purpose sat unused.

relayRoutesSpec now rewrites host-relative upstreams (localhost, any
loopback IP, unspecified or empty host) to host.docker.internal before
rendering; LAN IPs and DNS names still pass verbatim. The rewrite
happens before the identity hash, so existing relays carrying the old
routes are recreated on the next up.

Co-Authored-By: Claude Fable 5 <[email protected]>
Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De Loof aab5819147 feat(provider): publish-endpoint deploys a network relay for provider services
A provider's resource lives outside the compose network: consumers could
only reach it through injected variables carrying a host-published
address — nothing like the compose-native experience of addressing a
service by name at its well-known port.

A provider may now publish where each endpoint of its resource actually
listens:

    {"type": "publish-endpoint", "message": "80=localhost:49152"}

The endpoint is announced as seen from the provider's host: the relay —
the component that knows it runs inside a container — rewrites loopback
or unspecified upstream hosts to host.docker.internal (resolved through
its injected host-gateway extra_host); routable addresses pass through.

When at least one endpoint is published, compose deploys a relay
container in place of the service: a minimal TCP forwarder (new relay/
directory, published as docker/compose-relay, overridable with
COMPOSE_RELAY_IMAGE for internal registries) joining the networks of the
services that depend on the provider service, aliased with the service
name. Consumers then use http://<service>:<port> as if the service were
a regular container.

The relay is a first-class project container — canonical name, standard
compose labels including config-hash (label-driven commands run without
the compose file keep seeing the service: ps, logs, stop, down) — plus
the com.docker.compose.relay label declaring its role:

- the reconciler already leaves provider services' containers alone, and
  the relay's identity hash (image + routes) makes up idempotent: kept
  when routes are unchanged, recreated otherwise;
- process-level commands (exec, cp) refuse a relay — there is no service
  process in it to act on;
- the up monitor excludes relays from the containers whose termination
  ends an attached up: they are long-lived infrastructure and would
  otherwise keep 'up' waiting forever.

The example provider demonstrates the flow behind PROVIDER_DEMO_ENDPOINT
(a detached helper serving a fixed HTTP response), backed by an e2e
scenario asserting the compose-native address works and exec is refused.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 17:03:23 +02:00
Nicolas De Loof 1a1df10713 e2e: version-proof git fixture init, Branch cuts from main
git init -b requires git >= 2.28; init followed by symbolic-ref names
the initial branch on any version. Branch() now returns the working
tree to main so successive calls cut from the same base instead of
stacking on the previous branch.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 16:53:33 +02:00
Nicolas De Loof 3a5353d4ec e2e: cover git and OCI remote stacks
The remote loaders had almost no end-to-end coverage: one test consumed
an OCI artifact (config only, plus an up declined at the prompt) and
nothing exercised the git loader at all.

Git scenarios run against a throwaway repository served over the smart
HTTP protocol by an in-process server — git http-backend as a CGI per
httptest request: no daemon, no container, no fixed port. Smart HTTP is
a hard requirement: the loader ls-remotes the ref then shallow-fetches
the raw commit, which the dumb protocol cannot serve (no shallow
capability) and which needs uploadpack.allowAnySHA1InWant. Covered:
deploying from the default branch with repository-relative files,
selecting a #branch, selecting a #ref:subdir project.

OCI scenarios publish the fixture to a throwaway local registry (the
TestPublish pattern) then deploy from oci://: a full up consuming the
bundled env-file layer, and tag selection between two published
revisions.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 16:53:33 +02:00
Nicolas De Loof 92cda59f4a e2e: scenario DSL learns remote compose sources
FromRemote switches a scenario's -f to a reference handled by compose's
remote loaders (git URL, oci:// artifact) with optional root flags such
as --insecure-registry, while the anchored testdata copy keeps serving
as the local content the remote is built from. ContainerEnv joins the
check vocabulary: the container-config environment is the natural
observable that a remote project's bundled files (env_file) were
consumed, whatever the source of the model.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 16:53:33 +02:00
Nicolas De Loof e64f64bf87 fix(e2e): require every listed entry for the service to match state
RequireEventuallyServiceState succeeded on the first NDJSON entry for
the service matching the expected state, without checking any
remaining entries. During a recreation window compose ps can list two
containers for the same (scale: 1) service simultaneously (old and
new); the old one could still read "running" while the new one is
"starting", passing the poll prematurely on stale state.

Track whether any entry for the service was seen, and only succeed
once every one of them matches -- fail (poll.Continue) on the first
mismatch instead.

(docker-agent review on #14228)

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 14:31:15 +02:00
Nicolas De Loof c1fe57b62a fix(e2e): parse compose ps --format=json as NDJSON
`--format=json` prints one JSON object per line, not a single object:
RequireEventuallyServiceState's json.Unmarshal into a single map broke
as soon as more than one container was listed for the service (a
scaled service, or the transient window during recreation where the
old and new containers are both listed) -- every poll iteration
reported "invalid JSON" and the function always timed out instead of
detecting the actual state.

Scan stdout line by line, matching entries by Service, and succeed as
soon as one reaches the expected state. Also check the exit code
before parsing so a real `compose ps` failure is reported as such
instead of a misleading "invalid JSON" message.

(docker-agent review on #14228)

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 14:31:15 +02:00
Nicolas De Loof 5c11397e58 test(e2e): wait on state, not on an unsynchronized log line
TestAttachRestart and TestUpDependenciesNotStopped each flaked on an
assertion checked exactly once, right after an unrelated signal on a
different channel to the daemon:

- TestAttachRestart counted "failing-1  | world" immediately after a
  WaitForCondition on "failing-1 exited with code 1" reached 3. The
  exited status comes from the /events stream; the log line comes
  from a separate /logs?follow=1 connection (followStartedContainers
  in pkg/compose/up.go). Nothing orders one relative to the other, so
  the last restart's log line can still be in flight the instant the
  3rd exited is observed.

- TestUpDependenciesNotStopped called RequireServiceState right after
  seeing "hello app" in the attached log stream. That line reaching
  the test is no guarantee the daemon-reported container state (read
  via a fresh `compose ps` in a brand new process) has caught up yet.

Neither is a compose bug: `ps`/ContainerList and the log stream are
independent channels with no cross-channel ordering guarantee from
the engine, and compose does not cache container state. Both tests
now poll for the actual condition instead of using a log line as a
proxy for it: TestAttachRestart gets a second, bounded
WaitForCondition on the log count, and TestUpDependenciesNotStopped
uses a new RequireEventuallyServiceState (pkg/e2e/assert.go) that
polls `compose ps` instead of checking it once.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 14:31:15 +02:00
Nicolas De Loof 01a60a5e99 fix(up): drop benign context.Canceled noise from the final report
The new TestUpAttachedTerminatesOnExternalStop (#13990) occasionally
observed `up` exit 1 instead of 0 after an external stop/down cancels
a service's restart backoff. The cause: once the monitor detects
termination and cancels globalCtx, a log-streaming goroutine still
blocked on logs?follow=1 for the backoff container gets a
context.Canceled error, which appendErr collected unfiltered into the
final errors.Join.

That race could only surface once #13990 made the monitor loop
actually return in this scenario -- before that fix, up hung forever
and never reached this code path.

appendErr now drops an error that is nothing more than fallout from
our own shutdown (errors.Is(err, context.Canceled) once
u.globalCtx.Err() is set), the same way a SIGINT-triggered teardown's
residual goroutine errors are already expected to be harmless. A
genuine error is still reported regardless of shutdown state.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 13:54:05 +02:00
Nicolas De Loof 6970069cc6 fix(dry-run): commit no longer creates a real image; the interception set becomes declarative
compose commit --dry-run delegated ContainerCommit to the real client
and actually created the image. It now returns a dry-run ID without
touching the engine.

Auditing the rest of the client surface: 39 more mutating operations
were delegated — none that compose itself issues (verified caller by
caller), but any future code path reaching one would mutate the engine
under --dry-run silently. They now refuse with an explicit error.

The classification — read-only delegated / faked / refused — was only
implied by a method's position under a comment; it is now declared in
dryrunclient_test.go, which parses the client and fails on any method
missing, misclassified, or new and unclassified: extending APIClient
requires an explicit dry-run decision.

The commit e2e scenario gains a --dry-run step asserting the image is
NOT created (new ImageAbsent check).

Epic #14074, B.7.

Signed-off-by: Nicolas De Loof <[email protected]>
2026-09-16 13:44:21 +02:00