Skip to content

fix(up): interpolate every value that reaches container run, and cover the call sites - #153

Open
Mikimoto wants to merge 5 commits into
Mcrich23:mainfrom
Mikimoto:fix/network-name-consumers
Open

Mikimoto wants to merge 5 commits into
Mcrich23:mainfrom
Mikimoto:fix/network-name-consumers

Conversation

@Mikimoto

@Mikimoto Mikimoto commented Sep 9, 2026

Copy link
Copy Markdown

Summary

Compose variables were resolved for some values on the way to container run and not others, so a compose file that parameterises its network names, image tags, labels or service environment got the literal ${VAR} passed through to the daemon. Each is a separate one-line omission with the same shape, and resolveVariable — already in this repo and already used for ports — handles all of them.

Found while bringing a 23-service compose file up on Apple Container; every failure below is one that file hit.

What's fixed

Top-level network name: — setupNetwork used networkConfig?.name ?? networkName raw, so name: ${INGRESS_NET:-local} reached container network create, which rejects it:

Creating network: reverse-proxy (Actual name: ${REVERSE_PROXY_NETWORK_NAME:-dev-cluster-local-ingress})
Error: invalid network name: ${REVERSE_PROXY_NETWORK_NAME:-dev-cluster-local-ingress}

Its two consumers — fixing the create path alone was a half-fix. The --network argument and the host-gateway lookup both still took .name raw, so the network was created under the interpolated name and then joined by the literal one:

Error: network ${REVERSE_PROXY_NETWORK_NAME:-dev-cluster-local-ingress} not found

The gateway lookup is the quieter of the two: a miss there only warns before falling back to the host's LAN default route, so the wrong address reaches the container silently.

Image references and label values — image: foo:${TAG} and labels: {v: "${VERSION}"} were passed through unresolved.

Service environment: — configService resolved these with its own parser: strip a leading ${, drop the last character, look the remainder up. That matches only a value which is exactly ${NAME}. ${NAME:-default} and ${NAME:?message} yielded a "variable name" carrying the whole modifier, missed the lookup and fell through; so did a reference with surrounding text, and a value holding two of them. An LDAP server started with the literal ${LLDAP_BASE_DN:?LLDAP_BASE_DN is required} as its base DN and refused to bind.

cpus rounding — container run --cpus takes a whole number; cpus: 0.5 was passed through and rejected. Rounds up, and says so.

Test seams

Three commits here are pure refactors, and they are the point rather than tidying.

The first version of the image and label fixes was tested by calling resolveVariable directly, so removing both call sites left all eight tests green. The same trap caught the network fix later: every existing test called resolvedNetworkName itself, and stayed green for as long as configService was not calling it — which is exactly how the consumer bug survived.

So each fix now has a pure function that owns the whole decision — resolvedImageReference, labelRunArgs, networkRunArgs, gatewayNetworkName, interpolatedEnvironment, builderCPUCount — and the tests go through those. Verified by mutation: reverting each body to the old behaviour turns the intended tests red and only those.

Verification

swift test --filter Container_Compose_StaticTests
✔ Test run with 267 tests in 25 suites passed

Independent of #144/#145 — no symbol from that work appears here, so this can be reviewed on its own.

@Mcrich23

Mcrich23 commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Thank you for documenting the failures. I found a case that needs addressing: environment: {FOO: "${FOO:-fallback}"} can keep resolving against itself when FOO isn't set externally. The earlier merge also still discards some overrides containing ${. Can you add regressions for both and coordinate with #102? Please move CPU rounding into a separate PR and validate the numeric range before converting to Int.

@Mikimoto

Mikimoto commented Sep 15, 2026 •

Copy link
Copy Markdown
Author

Thank you for documenting the failures. I found a case that needs addressing: environment: {FOO: "${FOO:-fallback}"} can keep resolving against itself when FOO isn't set externally. The earlier merge also still discards some overrides containing ${. Can you add regressions for both and coordinate with #102? Please move CPU rounding into a separate PR and validate the numeric range before converting to Int.

Thanks — and the ${FOO:-fallback} case is a real one. I traced it rather than patching the symptom, because the root cause changes the fix.

Self-resolution. interpolatedEnvironment is environment.mapValues

{ resolveVariable($0, with: environment) } 

it resolves each value against the service's own environment: block. So for FOO: "${FOO:-fallback}" the lookup finds FOO already present, holding the unresolved literal, and substitutes that back into itself. The default never gets a chance. The correct source is the external environment (process env plus .env), which is what Compose interpolates against; a service's own declared value must not be visible to its own interpolation. Fixing the lookup source rather than adding a self-reference guard, since the guard would still leave A: "${B}" / B: "${A}" resolving against the wrong table.

Discarded ${ overrides. I haven't reproduced this one yet — I'll build the regression first and let it tell me the shape before I touch the merge logic, rather than guessing at which overrides are affected.

Coordination with #102. Will read it before writing anything. Same area, and I'd rather rebase onto or defer to that work than hand you two conflicting interpolation changes — tell me if you'd prefer #102 to land first and this PR to be rewritten on top.

CPU rounding. Agreed it doesn't belong here; it's two commits (the rounding and its test seam) and I'll move them to their own PR. On the range check: Int(someDouble) in Swift traps on NaN, infinity and anything outside Int's range, so a hostile or fat-fingered cpus: is a crash rather than a bad value. The split-out PR will validate before converting and reject with a message naming the service.

A compose file that selects its ingress network per environment writes

  networks:
    reverse-proxy:
      name: ${INGRESS_NET:-local-ingress}

The service-level network reference is resolved at the run-args site, but the
top-level name was passed through raw, so container rejected it:

  Creating network: reverse-proxy (Actual name: ${INGRESS_NET:-local-ingress})
  Error: invalid network name: ${INGRESS_NET:-local-ingress}

environmentVariables is already an instance property here, so this was an
omission rather than a structural limit.

The resolution is extracted as a pure function because setupNetwork has no test
seam - the same reason healthcheck.timeout could be decoded, asserted on by two
parsing tests, and still never reach the runtime.
Third and fourth instance of the same shape as the network name. This project
resolves ${VAR} field by field rather than in one pass after parsing, so each
field is a separate chance to forget. An audit of ComposeUp.swift found 17 call
sites that resolve and these that did not: image, labels, container_name, user,
platform, entrypoint, command, volumes.

Measured against a real 23-service compose file, only three fields carried
variables: image (19 services), labels (2) and ports (7, already resolved). The
first two are fixed here; the rest are left alone rather than changed
speculatively, and the audit is in the PR description so the shape is visible.

Without this, `image: repo/name:${TAG:-1.0}` reaches container verbatim:

  Pulling Image docker.io/lldap/lldap:${LLDAP_VERSION:-2026-05-26-alpine}...
  Error: invalid format for image reference

The test suite is renamed to Compose Value Interpolation and documents which
fields are covered, so the next field is added to a list rather than to a
one-off test somewhere else.

It also pins a rule that was previously unguarded: resolveVariable gives the
process environment precedence over the map it is handed, which is the Compose
rule (a shell value beats a .env value). An earlier draft of these tests used a
variable name that happened to be exported and failed only on this machine;
without a test naming the rule, the obvious "fix" is to invert the merge and
silently break it.
The previous commit added the interpolation but tested it by calling
resolveVariable from the test, which passes whether or not the call site uses
it - mutation-checked: removing both call sites left all 8 tests green. That is
the same vacuous shape this change set exists to remove, reproduced by the
author one commit after arguing against it.

Both mappings are now pure functions the tests call directly. Extracting
labelRunArgs also brought two documented-but-unguarded behaviours under test:
the compose project/service labels overriding a user label of the same key, and
the argv being sorted for determinism.
`setupNetwork` creates a top-level network under the interpolated name
(`resolvedNetworkName`), but two consumers still took the declared
`name:` raw:

- the `--network` arg read `networks?[key]??.name` directly, so a
  compose file declaring `name: ${INGRESS_NET:-dev-cluster-local-ingress}`
  created `dev-cluster-local-ingress` and then asked `container run` to
  join the literal `${INGRESS_NET:-dev-cluster-local-ingress}`, which
  fails with "network ... not found".
- the host-gateway lookup interpolated the network *key* and ignored a
  `name:` override, so `container network inspect` missed and it fell
  back to the host's LAN default-route gateway. That path only warns,
  so the wrong address reaches the container silently.

Both now go through `resolvedNetworkName`, the same mapping the create
path uses. Its doc comment already claimed the service-level reference
was interpolated "for the same reason"; that is now true.
The previous commit fixed both network-name consumers, but nothing would
have caught them regressing: every existing test calls
`resolvedNetworkName` directly, and those stayed green for as long as the
bug existed precisely because `configService` did not call it.

Extract the two decisions `configService` makes into pure functions —
`networkRunArgs` (every `--network` argument a service contributes) and
`gatewayNetworkName` (the network whose gateway `host-gateway` means) —
and test both through a decoded compose document that declares
`name: ${CC_TEST_INGRESS:-fallback-ingress}`.
@Mikimoto

Copy link
Copy Markdown
Author

Pushed, rebased onto current main. This PR is now smaller in two directions.

Service-environment interpolation is deferred to #102. I read it before touching anything, as promised. #102's mergeServiceEnvironment already does this, and does it correctly:

combined[key] = resolveVariable(value, with: envVars)   // #102 — resolves against the external env

whereas the commit that was here resolved against the service's own block:

environment.mapValues { resolveVariable($0, with: environment) }   // this PR — resolves against itself

That second form is the ${FOO:-fallback} bug you found, and the root cause is the lookup source, not a missing self-reference guard: with FOO present in the table holding its own unresolved literal, the default never gets a chance, and a self-reference guard would still leave A: "${B}" / B: "${A}" resolving against the wrong table. #102 has the right source, so that commit is dropped rather than fixed here — one interpolation path for service env, in the PR that already owns it.

The "earlier merge still discards some overrides containing ${" report lands in the same area, so I'm leaving it with #102 too rather than building a competing fix. Happy to write the regression for it there if that helps.

CPU rounding is now #160. Two commits moved out, plus the range check you asked for. Int(Double) traps rather than saturating, and three shapes a compose file can legally contain crashed the process: 1e400 (infinity), nan, and 1e20 — finite but larger than Int can hold, which an isFinite check alone still misses. Verified by removing the guard: the test process dies with signal 5. Details are on that PR.

What's left here is the part neither #102 nor #160 touches — the values that reach container run from outside the service's environment block:

  • fix(up): interpolate the top-level network name — ${VAR} reached container verbatim as "invalid network name"
  • fix(up): interpolate image references and label values — same, as "invalid format for image reference"
  • fix(up): connect and inspect networks under their created name
  • two test seams, so the mapping is covered where it is decided rather than at the call site

Five commits, down from eight. Static suite: 259 tests / 24 suites green.

Say the word if you'd rather #102 land first and I rebase what's left on top of it — there's no overlap now, but the ordering is yours.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants