Skip to content

Add container compose MVP for multi-service workflows#1394

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

Add container compose MVP for multi-service workflows#1394
mohammedNali wants to merge 2 commits intoapple: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.

Copilot AI review requested due to automatic review settings April 5, 2026 20:36
Copy link
Copy Markdown

Copilot AI left a comment

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
File Description
Tests/ContainerCommandsTests/ComposeSupportTests.swift Unit tests for discovery, interpolation, validation, normalization, profiles, and command tokenization.
Tests/CLITests/Subcommands/Compose/TestCLICompose.swift CLI regression tests for compose up and service-name DNS resolution.
Sources/ContainerCommands/Compose/ComposeSupport.swift Compose parsing/validation/interpolation and project normalization primitives.
Sources/ContainerCommands/Compose/ComposeCommand.swift container compose CLI surface + executor logic for networks/volumes, lifecycle, logs, and healthchecks.
Sources/ContainerCommands/Application.swift Wires the new Compose command group into the root CLI.
README.md Documents Compose MVP availability and points to feature brief.
Package.swift Adds Yams dependency and a new ContainerCommandsTests target.
Package.resolved Locks Yams dependency resolution.
docs/tutorial.md Adds a pointer to Compose docs from the tutorial.
docs/how-to.md Adds a new “Run a multi-service Compose project” guide section.
docs/compose-feature-brief.md New implementation/design brief for the Compose feature.
docs/command-reference.md Adds container compose reference docs, options, and examples.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

return exitCode == 0
}
group.addTask {
try await Task.sleep(for: healthcheck.timeout)
Copy link

Copilot AI Apr 5, 2026

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
try await Task.sleep(for: healthcheck.timeout)
do {
try await Task.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 }
Copy link

Copilot AI Apr 5, 2026

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)
Copy link

Copilot AI Apr 5, 2026

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
try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans)
try await ComposeExecutor(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)
Copy link

Copilot AI Apr 5, 2026

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)
}
}
}
}
Copy link

Copilot AI Apr 5, 2026

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] ?? "")" }
}

Copy link

Copilot AI Apr 5, 2026

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
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] ?? "")" }
}

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>
@mohammedNali mohammedNali force-pushed the feature/compose-mvp branch from 152ba79 to b0088e9 Compare April 5, 2026 20:47
@jglogan
Copy link
Copy Markdown
Contributor

jglogan commented Apr 6, 2026

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

@jglogan jglogan closed this Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Request]: Docker Compose Support

3 participants