Add container compose MVP for multi-service workflows#1394
Add container compose MVP for multi-service workflows#1394mohammedNali wants to merge 2 commits intoapple:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces first-class MVP Docker Compose-style workflows into the container CLI, enabling local multi-service orchestration from common Compose file names without relying on an external plugin.
Changes:
- Added
container composecommand group withconfig,up,down,ps, andlogssubcommands, plus an execution layer for orchestration and healthcheck-aware dependency waiting. - Implemented Compose YAML loading, env interpolation, validation of unsupported keys, normalization (project-scoped networks/volumes, topo ordering, labels), and service-name networking.
- Added unit/integration-style tests plus documentation updates, and introduced Yams as the YAML parser dependency.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/ContainerCommandsTests/ComposeSupportTests.swift | Unit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization. |
| Tests/CLITests/Subcommands/Compose/TestCLICompose.swift | CLI regression tests for compose up and service-name DNS resolution. |
| Sources/ContainerCommands/Compose/ComposeSupport.swift | Compose parsing/validation/interpolation and project normalization primitives. |
| Sources/ContainerCommands/Compose/ComposeCommand.swift | container compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks. |
| Sources/ContainerCommands/Application.swift | Wires the new Compose command group into the root CLI. |
| README.md | Documents Compose MVP availability and points to feature brief. |
| Package.swift | Adds Yams dependency and a new ContainerCommandsTests target. |
| Package.resolved | Locks Yams dependency resolution. |
| docs/tutorial.md | Adds a pointer to Compose docs from the tutorial. |
| docs/how-to.md | Adds a new “Run a multi-service Compose project” guide section. |
| docs/compose-feature-brief.md | New implementation/design brief for the Compose feature. |
| docs/command-reference.md | Adds container compose reference docs, options, and examples. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return exitCode == 0 | ||
| } | ||
| group.addTask { | ||
| try await Task.sleep(for: healthcheck.timeout) |
There was a problem hiding this comment.
runHealthcheckProbe uses withThrowingTaskGroup with a timeout task that calls Task.sleep. When group.cancelAll() is invoked after the first result, the timeout task will typically be cancelled and Task.sleep will throw CancellationError, which can cause the whole group to throw even though the healthcheck already completed. Catch CancellationError inside the timeout task (or use withTaskGroup + manual error handling) so cancellation doesn’t surface as a failure, and ensure only the real process.wait() error propagates.
| try await Task.sleep(for: healthcheck.timeout) | |
| do { | |
| try await Task.sleep(for: healthcheck.timeout) | |
| } catch is CancellationError { | |
| return false | |
| } |
| var merged = ProcessInfo.processInfo.environment | ||
|
|
||
| let defaultEnvURL = projectDirectory.appendingPathComponent(".env") | ||
| if FileManager.default.fileExists(atPath: defaultEnvURL.path(percentEncoded: false)) { | ||
| merged.merge(try parseEnvFile(defaultEnvURL)) { _, new in new } |
There was a problem hiding this comment.
container compose interpolation env precedence appears inverted: starting from ProcessInfo.processInfo.environment and merging .env with { _, new in new } makes .env override already-set environment variables. Docker Compose variable substitution treats shell env as higher precedence than .env (and usually env files provide defaults). Consider merging so existing values win (or otherwise matching Compose precedence rules).
|
|
||
| func run() async throws { | ||
| let project = try loadComposeProject() | ||
| try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans) |
There was a problem hiding this comment.
--remove-orphans is documented as “accepted for compatibility” but is currently treated as an alias for --volumes via removeVolumes || removeOrphans, which will delete named volumes unexpectedly. Either make --remove-orphans a no-op (per help text) or implement actual orphan removal semantics without tying it to volume deletion.
| try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans) | |
| try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes) |
| let keyed = try decoder.container(keyedBy: CodingKeys.self) | ||
| self.raw = nil | ||
| self.hostIP = try keyed.decodeIfPresent(String.self, forKey: .hostIP) | ||
| self.published = try keyed.decodeIfPresent(String.self, forKey: .published) | ||
| self.target = try keyed.decode(String.self, forKey: .target) |
There was a problem hiding this comment.
Compose long-syntax ports commonly use numeric YAML values (e.g. target: 80, published: 8080). This decoder requires String for target/published, so those valid Compose files will fail to decode. Consider decoding Int or String (similar to ComposeDuration) and normalizing to a string for asPublishSpec().
| let stream = AsyncStream<String> { continuation in | ||
| handle.readabilityHandler = { logHandle in | ||
| let data = logHandle.availableData | ||
| if data.isEmpty { | ||
| continuation.finish() | ||
| return | ||
| } | ||
| if let text = String(data: data, encoding: .utf8) { | ||
| for line in text.components(separatedBy: .newlines).filter({ !$0.isEmpty }) { | ||
| continuation.yield(line) | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
followLog sets handle.readabilityHandler but never clears it on stream termination, unlike ContainerLogs.followFile. This can keep the handler alive after completion and can interfere with future reads on the same handle. Clear readabilityHandler (e.g. in AsyncStream’s termination handler) and consider matching the restart/seek behavior used in ContainerLogs.
| private func mergeKeyValueEntries(_ entries: [String]) -> [String] { | ||
| var merged: [String: String] = [:] | ||
| for entry in entries { | ||
| let parts = entry.split(separator: "=", maxSplits: 1) | ||
| let key = String(parts[0]) | ||
| let value = parts.count == 2 ? String(parts[1]) : "" | ||
| merged[key] = value | ||
| } | ||
| return merged.keys.sorted().map { "\($0)=\(merged[$0] ?? "")" } | ||
| } | ||
|
|
There was a problem hiding this comment.
There are two mergeKeyValueEntries implementations in this file: one as ComposeProject.mergeKeyValueEntries and another free function at the bottom. The free function appears unused and duplicates logic, increasing maintenance cost. Remove the unused helper or consolidate to a single implementation.
| private func mergeKeyValueEntries(_ entries: [String]) -> [String] { | |
| var merged: [String: String] = [:] | |
| for entry in entries { | |
| let parts = entry.split(separator: "=", maxSplits: 1) | |
| let key = String(parts[0]) | |
| let value = parts.count == 2 ? String(parts[1]) : "" | |
| merged[key] = value | |
| } | |
| return merged.keys.sorted().map { "\($0)=\(merged[$0] ?? "")" } | |
| } |
Implements container compose with subcommands: config, up, down, ps, logs. Supports: image, build, command, entrypoint, environment, env_file, ports, volumes, depends_on (with service_started/service_healthy conditions), networks, working_dir, user, tty, stdin_open, profiles, healthcheck. Direct CLI integration using Yams for YAML parsing. Includes topology-based service ordering, config-hash change detection, and healthcheck-aware dependency waiting. Unsupported fields fail validation explicitly. See docs/compose-feature-brief.md for implementation details. AI-assisted implementation. Signed-off-by: mohammedNali <mohammednjmali@gmail.com>
152ba79 to
b0088e9
Compare
|
@mohammedNali thank you for the contribution, but we don't intend to upstream a compose-like feature directly into the project at this point. Please see the discussions here for more background: |
Summary
This PR adds first-class Compose workflow support directly in the container CLI for local multi-service development.
Motivation
Addresses feature requests #230, #208, #235, #55. Provides a clean, direct CLI integration alternative to plugin-based approaches in #239 and #398.
Implementation
Direct CLI integration (not a plugin):
compose config,compose up,compose down,compose ps,compose logsSupported Compose Fields
Top-level:
name,services,networks,volumesService:
image,build(context, dockerfile, args, target),command,entrypoint,environment,env_file,ports,volumes,depends_on(withcondition),networks,working_dir,user,tty,stdin_open,profiles,healthcheckUnsupported fields fail validation with explicit errors.
Testing
Limitations (MVP)
Files Changed
Sources/ContainerCommands/Compose/(2 files, ~1,850 lines)Tests/*/Compose/(2 files, ~310 lines)docs/compose-feature-brief.md(implementation documentation)Package.swift(added Yams), README, docs, Application.swiftTotal: ~2,456 lines of new code
Related
See
docs/compose-feature-brief.mdfor detailed implementation notes.Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.