feat(service): add Compose profiles support#126
Merged
Conversation
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.
ryan106
force-pushed
the
feat/profile-support
branch
from
July 2, 2026 08:50
59bc680 to
ddeb51d
Compare
5 tasks
Cyb3rDudu
approved these changes
Jul 4, 2026
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.
This was referenced Jul 8, 2026
Cyb3rDudu
pushed a commit
that referenced
this pull request
Jul 22, 2026
* Add shared ComposeProjectOptions for compose-file/service resolution Introduce a reusable ComposeProjectOptions (compose-file location + decode, project-name derivation, dependency-ordered service resolution, container-name mapping) and a resolved ComposeProject value. New subcommands adopt this instead of re-duplicating the boilerplate that up/down/build/logs each grew. * test(project): cover ComposeProjectOptions file discovery, naming, and ordering Covers composePath (--file precedence, discovery order, default), projectName fallback to the derived cwd name, and orderedServices topological sort/filtering. * feat(project): resolve services to candidate container names, not one guess ComposeProject mapped each service to a single container name (container_name ?? <project>-<service>), which misses the dotted <service>.<dnsDomain> names 'up' uses when the project's DNS domain is registered (#97) — the same bug class fixed for ctrl-c in #127. Replace ServiceTarget.containerName with candidateContainerNames (explicit name first, then both conventions, mirroring ComposeDown.stopOldStuff) and add existingContainer(using:) to resolve which candidate actually exists, so every subcommand built on ComposeProject shares one correct lookup instead of re-deriving names. * feat(project): delegate service selection to Service.selectServices orderedServices hand-rolled its own name filter, which ignored Compose profiles gating and didn't expand depends_on — diverging from the shared selection algorithm up/build/down adopted in #126. Route it through Service.selectServices with the ComposeFileOptions active profiles so commands built on ComposeProject select identically to the existing ones: profile-eligible services plus dependencies by default, requested services plus transitive dependencies (bypassing the profile gate) when named explicitly. * refactor(down): resolve project through shared ComposeProjectOptions First consumer of ComposeProject: drop ComposeDown's own copies of compose-file discovery, project-name derivation, topo-sort/selection, and candidate-container-name construction in favor of the shared API. Behavior is preserved (profile skip note, stop-all-candidates loop for mixed naming-mode leftovers, extra_hosts cleanup) with one refinement: explicit 'down <service>' now expands dependencies via Service.selectServices instead of the topo-sort dependedBy side effect, which only recorded each service's first visitor and so stopped an explicit request's dependencies only when visit order happened to align. * refactor(build): resolve project through shared ComposeProjectOptions Drop ComposeBuild's copies of compose-file discovery, project-name derivation, and service selection in favor of ComposeProjectOptions. Selection semantics are unchanged (Service.selectServices with active profiles, then keep services with a build config); ordering improves from dictionary order to topological, so dependencies build before dependents. * refactor(up): resolve project through shared ComposeProjectOptions Drop ComposeUp's copies of compose-file discovery/decoding, project-name derivation, topo-sort/selection, and the hand-built stop-candidates list in favor of ComposeProjectOptions and ComposeProject.candidateContainerNames. Behavior is unchanged: 'up' keeps deciding the single creation-time name itself (explicit container_name, dotted DNS name when the domain is registered, else <project>-<service>) since that depends on runtime DNS availability, and the pre-start cleanup still stops every name shape a previous run might have used. * refactor(project): simplify shared resolution layer and its consumers Cleanups from a reuse/simplification/efficiency/altitude review of the branch: - down: resolve once instead of loadCompose()+resolve() (the compose file was read and YAML-decoded twice per run); drop the stop loop's remove parameter, which was always false - up: consume resolve() instead of re-composing loadCompose + projectName + orderedServices + candidate names piecewise; this also gives up the same 'No such service' warning down/build already had - move the 'No such service' warning from resolve() into orderedServices() so every selection path warns, not just resolve() - hoist the envFilePath derivation duplicated verbatim in up and build onto ComposeProjectOptions - move sanitizeDnsDomain from ComposeUp to ComposeProject: it is project naming policy, and the shared layer should not reach into a command for it - delete dead code: ServiceTarget.existingContainer (no callers, and its first-match semantics is the exact behavior down's stop-every- candidate loop exists to avoid), ComposeProject.cwd (never read), ComposeError.invalidProjectName (no throwers left), and ComposeUp's vacuous projectName optionality (guards that could never fire once resolution guarantees a name) - narrow supportedComposeFilenames to private * fix(up): resolve healthcheck container name instead of assuming <project>-<service> waitUntilServiceIsHealthy rebuilt the container name by hand, so healthchecks for services created under an explicit container_name or the dotted <service>.<dnsDomain> DNS-mode name (#97) exec'd against a nonexistent container and burned every retry before failing. Use containerName(for:), which reads the resolved name configService records before any healthcheck runs — the same fix #127 applied to the foreground stop paths. * test: cover loadCompose missing-file throw and healthcheck name resolution Closes the two meaningful coverage gaps over this branch's diff: the composeFileNotFound path in ComposeProjectOptions.loadCompose, and a dynamic regression test proving healthchecks exec against the resolved container name — a service created under an explicit container_name previously health-checked the nonexistent <project>-<service> and failed up after exhausting retries.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The Compose spec lets services be gated behind named profiles:
docker composeonly startsdebug-toolswhendebugis active (--profile debugorCOMPOSE_PROFILES=debug);container-composehas noprofilessupport 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
profiles: [String]?toService(plain list of strings per spec — no shorthand string form).--profileflag to the sharedComposeFileOptionsgroup (used byup/down/build), plus support for the spec'sCOMPOSE_PROFILESenvironment variable (comma-separated), merged with--profile.Service.isProfileEligible(activeProfiles:)implements the spec's default gate: noprofiles(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 forup,build, anddown: it seeds from either the explicitly requested services or the profile-eligible set, then walksdepends_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 ownprofiles.ComposeBuildandComposeDownreuseselectServicesfor their own default (no explicit service args) selection, sobuild/up/downbehave consistently for the same profile-gated dependency graph.downadditionally 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.swift—profilesfield + decoding,isProfileEligible(activeProfiles:), and the sharedselectServices(from:requestedServices:activeProfiles:)(moved here alongsidetopoSortConfiguredServicessince it's now used by all three subcommands, not justup).Sources/Container-Compose/Commands/ComposeFileOptions.swift—--profileoption +activeProfiles(merges withCOMPOSE_PROFILES).Sources/Container-Compose/Commands/ComposeUp.swift— callsService.selectServices.Sources/Container-Compose/Commands/ComposeBuild.swift,ComposeDown.swift— default-selection paths now reuseService.selectServicesfor parity withup.Tests/Container-Compose-StaticTests/ProfilesTests.swift— decoding, eligibility, default/active/explicit/dependency selection, CLI parsing, andCOMPOSE_PROFILESmerging (suite marked.serializedsince two tests mutate the process environment).Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift— updated one existing call site for theselectServicesrename.Sources/Container-Compose/Commands/ComposeBuild.swift— also routes the explicit-service-name path throughService.selectServices(previously only the default path did), socontainer-compose build appnow also buildsapp'sdepends_ongraph — matchingup/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:
ContainerDependentTraitwas crashing every dynamic testWhile validating this locally, I found
ContainerDependentTrait(used by every.containerDependentsuite —ComposeUpTests,ComposeDownTests,ComposeBuildTests,ComposeUpEntrypointTests,ComposeUpDnsTests) was failing at suite setup withCaught 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 thecontainer-apiserverbinary relative toCommandLine.executablePath, which is correct when invoked as the realcontainerCLI but resolves to the test binary's own path when called in-process from the trait. Fixed by shelling out to the real installedcontainerbinary instead (Sources/Container-Compose/Commands/ComposeUp.swiftalready does the same for othercontainersubcommands).This unblocked every dynamic suite — they'd never actually executed before. That surfaced two stale assertions in
testWordPressComposeexpecting 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.containerDependentsuites now pass cleanly when run individually. (CI itself is unaffected either way — it only runsContainer_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 measuresgetrusage(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.