Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Add container compose MVP for multi-service workflows by mohammedNali · Pull Request #1394 · apple/container · GitHub
Skip to content

Add container compose MVP for multi-service workflows - #1394

Closed
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp
Closed

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits into
apple:mainfrom
mohammedNali:feature/compose-mvp

Conversation

@mohammedNali

Copy link
Copy Markdown

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):

  • Commands: compose config, compose up, compose down, compose ps, compose logs
  • YAML parsing via Yams
  • Environment variable interpolation
  • Project-scoped resources (volumes/networks)
  • Config-hash based change detection
  • Topology-based service ordering
  • Healthcheck-aware dependency waiting

Supported Compose Fields

Top-level:name, services, networks, volumes

Service:image, build (context, dockerfile, args, target), command, entrypoint, environment, env_file, ports, volumes, depends_on (with condition), networks, working_dir, user, tty, stdin_open, profiles, healthcheck

Unsupported fields fail validation with explicit errors.

Testing

  • Unit tests for parsing, interpolation, validation, normalization
  • Integration tests for service orchestration, DNS resolution
  • Validated against real Compose projects (PostgreSQL + MinIO + bootstrap)

Limitations (MVP)

  • One container per service (no scaling)
  • Unsupported fields fail explicitly (no silent partial behavior)

Files Changed

  • New: Sources/ContainerCommands/Compose/ (2 files, ~1,850 lines)
  • New: Tests/*/Compose/ (2 files, ~310 lines)
  • New: docs/compose-feature-brief.md (implementation documentation)
  • Modified: Package.swift (added Yams), README, docs, Application.swift

Total: ~2,456 lines of new code

Related

See docs/compose-feature-brief.md for detailed implementation notes.


Note: This implementation was AI-assisted. I can explain and justify every design decision and line of code.

CopilotAI review requested due to automatic review settings April 5, 2026 20:36

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 compose command group with config, up, down, ps, and logs subcommands, 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
FileDescription
Tests/ContainerCommandsTests/ComposeSupportTests.swiftUnit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swiftCLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swiftCompose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swiftcontainer compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swiftWires the new Compose command group into the root CLI.
README.mdDocuments Compose MVP availability and points to feature brief.
Package.swiftAdds Yams dependency and a new ContainerCommandsTests target.
Package.resolvedLocks Yams dependency resolution.
docs/tutorial.mdAdds a pointer to Compose docs from the tutorial.
docs/how-to.mdAdds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.mdNew implementation/design brief for the Compose feature.
docs/command-reference.mdAdds 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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
tryawaitTask.sleep(for: healthcheck.timeout)
do{
tryawaitTask.sleep(for: healthcheck.timeout)
}catch is CancellationError {
return false
}

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +105
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 }

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

func run() async throws {
let project = try loadComposeProject()
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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.

Suggested change
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
tryawaitComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +733
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)

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copilot uses AI. Check for mistakes.
Comment on lines +692 to +705
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)
}
}
}
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +1092 to +1102
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] ?? "")" }
}

CopilotAIApr 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
privatefunc mergeKeyValueEntries(_ entries:[String])->[String]{
varmerged:[String:String]=[:]
for entry in entries {
letparts= entry.split(separator:"=", maxSplits:1)
letkey=String(parts[0])
letvalue= parts.count ==2?String(parts[1]):""
merged[key]= value
}
return merged.keys.sorted().map{"\($0)=\(merged[$0]??"")"}
}

Copilot uses AI. Check for mistakes.
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>
@jglogan

Copy link
Copy Markdown
Contributor

@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:

@jgloganjglogan closed this Apr 6, 2026
@luisnetoluisneto mentioned this pull request Apr 8, 2026
Sign up for freeto 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.

[Request]: Docker Compose Support

3 participants

@mohammedNali@jglogan