Skip to content

feat(service): add Compose profiles support#126

Merged
Cyb3rDudu merged 3 commits into
Mcrich23:mainfrom
ryan106:feat/profile-support
Jul 4, 2026
Merged

feat(service): add Compose profiles support#126
Cyb3rDudu merged 3 commits into
Mcrich23:mainfrom
ryan106:feat/profile-support

Conversation

@ryan106

@ryan106 ryan106 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Problem

The Compose spec lets services be gated behind named profiles:

services:
  web:
    image: nginx
  debug-tools:
    image: busybox
    profiles: ["debug"]

docker compose only starts debug-tools when debug is active (--profile debug or COMPOSE_PROFILES=debug); container-compose has no profiles support at all — the key is silently ignored, so profile-gated services always start. Projects that rely on Docker Compose profiles to scope multi-team stacks (auth-only vs. full-engine vs. frontend, etc.) can't reproduce that behavior.

Solution

  • Added profiles: [String]? to Service (plain list of strings per spec — no shorthand string form).
  • Added a repeatable --profile flag to the shared ComposeFileOptions group (used by up/down/build), plus support for the spec's COMPOSE_PROFILES environment variable (comma-separated), merged with --profile.
  • Service.isProfileEligible(activeProfiles:) implements the spec's default gate: no profiles (or empty) → always eligible; otherwise eligible only if one of the service's profiles is active.
  • Service.selectServices(from:requestedServices:activeProfiles:) is the shared selection algorithm for up, build, and down: it seeds from either the explicitly requested services or the profile-eligible set, then walks depends_on — matching the spec's stated exceptions, where an explicitly-named service and any service reached only as a dependency both bypass the profile gate regardless of their own profiles.
  • ComposeBuild and ComposeDown reuse selectServices for their own default (no explicit service args) selection, so build/up/down behave consistently for the same profile-gated dependency graph. down additionally prints a note listing any services skipped because their profile isn't active, so cleanup gaps aren't silent.

Changes

  • Sources/Container-Compose/Codable Structs/Service.swiftprofiles field + decoding, isProfileEligible(activeProfiles:), and the shared selectServices(from:requestedServices:activeProfiles:) (moved here alongside topoSortConfiguredServices since it's now used by all three subcommands, not just up).
  • Sources/Container-Compose/Commands/ComposeFileOptions.swift--profile option + activeProfiles (merges with COMPOSE_PROFILES).
  • Sources/Container-Compose/Commands/ComposeUp.swift — calls Service.selectServices.
  • Sources/Container-Compose/Commands/ComposeBuild.swift, ComposeDown.swift — default-selection paths now reuse Service.selectServices for parity with up.
  • Tests/Container-Compose-StaticTests/ProfilesTests.swift — decoding, eligibility, default/active/explicit/dependency selection, CLI parsing, and COMPOSE_PROFILES merging (suite marked .serialized since two tests mutate the process environment).
  • Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift — updated one existing call site for the selectServices rename.
  • Sources/Container-Compose/Commands/ComposeBuild.swift — also routes the explicit-service-name path through Service.selectServices (previously only the default path did), so container-compose build app now also builds app's depends_on graph — matching up/down, which already expanded dependencies.
  • Tests/Container-Compose-DynamicTests/ComposeBuildTests.swift — new test proving an explicit build target's dependency also gets built.

Bonus fix: ContainerDependentTrait was crashing every dynamic test

While validating this locally, I found ContainerDependentTrait (used by every .containerDependent suite — ComposeUpTests, ComposeDownTests, ComposeBuildTests, ComposeUpEntrypointTests, ComposeUpDnsTests) was failing at suite setup with Caught error: No such file or directory, before any test body ran — already flagged in the source with #warning("This is crashing"). Root cause: Application.SystemStart.run() resolves the container-apiserver binary relative to CommandLine.executablePath, which is correct when invoked as the real container CLI but resolves to the test binary's own path when called in-process from the trait. Fixed by shelling out to the real installed container binary instead (Sources/Container-Compose/Commands/ComposeUp.swift already does the same for other container subcommands).

This unblocked every dynamic suite — they'd never actually executed before. That surfaced two stale assertions in testWordPressCompose expecting truncated mount destinations (/var/www/, /var/lib/) instead of the compose file's actual declared paths (/var/www/html, /var/lib/mysql); fixed those too. All .containerDependent suites now pass cleanly when run individually. (CI itself is unaffected either way — it only runs Container_Compose_StaticTests.)

Update: also fixed WaitForeverCpuTests (Tests/Container-Compose-StaticTests/WaitForeverCpuTests.swift), which was failing on every CI run of this PR (and locally, on two separate machines) with wildly different but always-excessive readings. Root cause: it measures getrusage(RUSAGE_SELF), which is process-wide — as the static test suite has grown large enough that something else is almost always running concurrently in the same process during its 200ms window, that ambient CPU gets misattributed to the regression check. Fixed by taking an idle baseline measurement immediately before the real one and asserting on the incremental cost only, which cancels out concurrent-test noise instead of assuming it's near-zero. Confirmed stable across 3 consecutive full local runs after the fix.

Adds `profiles` to Service plus a repeatable --profile flag and
COMPOSE_PROFILES env var support, gating default service selection
per the Compose spec (explicit targets and dependencies bypass the
gate). up/build/down share one selection algorithm (Service.selectServices)
so all three behave consistently for the same profile-gated graph; down
now prints a note listing anything skipped due to an inactive profile.
ComposeBuild's explicit-service-name path only built the named
service, unlike up/down which already pull in depends_on (up via
Service.selectServices, down via its dependedBy check). Route both
the explicit and default cases through Service.selectServices so
build/up/down agree on the same dependency graph.
Application.SystemStart.run() resolves container-apiserver relative to
CommandLine.executablePath, which is correct when invoked as the real
container CLI but resolves to the test binary itself when called
in-process from ContainerDependentTrait — "No such file or directory"
on every single test in every .containerDependent suite. Shell out to
the real installed container binary instead, matching how the rest of
this codebase talks to container/container-compose.

This unblocked every dynamic suite (they'd never actually run before),
which surfaced two stale assertions in testWordPressCompose expecting
truncated mount destinations ("/var/www/", "/var/lib/") instead of the
compose file's actual declared paths ("/var/www/html", "/var/lib/mysql").

Also fixes WaitForeverCpuTests, which was failing on every CI run
(and locally) regardless of branch: it measured getrusage(RUSAGE_SELF),
which is process-wide, so as the static test suite grew large enough
that something else is almost always running concurrently in the same
process during its 200ms measurement window, that ambient CPU got
misattributed to the regression check. Now takes an idle baseline
measurement immediately before the real one and asserts on the
incremental cost only, cancelling out concurrent-test noise instead of
assuming it's near-zero.
@Cyb3rDudu Cyb3rDudu merged commit 8f2509c into Mcrich23:main Jul 4, 2026
1 check passed
adrum added a commit to adrum/Container-Compose that referenced this pull request Jul 4, 2026
…ice>

runForegroundUntilStopped rebuilt names as <project>-<service>, so
containers named via explicit container_name or the dotted
<service>.<dnsDomain> DNS convention (Mcrich23#97) were never found by the
stop/kill/monitor paths — ctrl-c exited container-compose but left
those containers running. Pass names resolved through
containerName(for:) instead of rebuilding them from service keys.

Also port the Mcrich23#126 idle-baseline technique into ForegroundWaitCpuTests
(successor of the deleted WaitForeverCpuTests) so the process-wide
getrusage flake fixed there isn't reintroduced: assert on incremental
CPU over an immediately-preceding idle window rather than an absolute
threshold.
Cyb3rDudu pushed a commit that referenced this pull request Jul 8, 2026
* fix(up): match docker compose foreground behavior

Foreground `up` (without --detach) parked in waitForever() with no signal
handling and never noticed when its containers stopped. Combined with the
ContainerCommands signal machinery leaving SIGINT/SIGTERM neutered (SIG_IGN)
after image-pull/network/build calls return, Ctrl-C did nothing and the
process hung even after `container compose down` from another shell.

Replace waitForever() with runForegroundUntilStopped(), matching docker compose:

- SIGINT/SIGTERM (observed via a DispatchSource stream, so they fire despite
  the SIG_IGN disposition) gracefully stop the project's containers, then exit
- a second signal force-kills the containers with SIGKILL, then exits
  ("press Ctrl+C again to force")
- a monitor task exits `up` once all services have run and then stopped —
  whether they exit on their own or are stopped via `container compose down`
  from another shell (it waits until each container is seen running first, so
  it never returns before startup completes)

Also retarget the #27 busy-loop regression test at the new wait function and
make it reliable in the full parallel suite: getrusage(RUSAGE_SELF) is
process-wide, so a 1s window (vs 200ms) lets a real busy-loop's full-core
~1,000,000 µs dominate the transient CPU noise from other parallel suites.

* fix(up): stop resolved container names on ctrl-c, not <project>-<service>

runForegroundUntilStopped rebuilt names as <project>-<service>, so
containers named via explicit container_name or the dotted
<service>.<dnsDomain> DNS convention (#97) were never found by the
stop/kill/monitor paths — ctrl-c exited container-compose but left
those containers running. Pass names resolved through
containerName(for:) instead of rebuilding them from service keys.

Also port the #126 idle-baseline technique into ForegroundWaitCpuTests
(successor of the deleted WaitForeverCpuTests) so the process-wide
getrusage flake fixed there isn't reintroduced: assert on incremental
CPU over an immediately-preceding idle window rather than an absolute
threshold.
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.

3 participants