diff --git a/Package.resolved b/Package.resolved index 3b29d5751..849cc5eb5 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "4ec05f4e83999a89d3397d0657536924d4a425d7f0e3f0fd6a3578e34c924502", + "originHash" : "d67ca18245e408d53d535d92d212d83e85309dac694df12299ef28b1b3249803", "pins" : [ { "identity" : "async-http-client", @@ -226,6 +226,15 @@ "version" : "1.6.2" } }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams.git", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } + }, { "identity" : "zstd", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index a9d7e90d2..f5c1ec854 100644 --- a/Package.swift +++ b/Package.swift @@ -55,6 +55,7 @@ let package = Package( .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), .package(url: "https://github.com/apple/swift-system.git", from: "1.4.0"), .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), + .package(url: "https://github.com/jpsim/Yams.git", from: "5.1.3"), .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), .package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"), ], @@ -88,6 +89,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Yams", package: "Yams"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), @@ -104,6 +106,12 @@ let package = Package( ], path: "Sources/ContainerCommands" ), + .testTarget( + name: "ContainerCommandsTests", + dependencies: [ + "ContainerCommands" + ] + ), .target( name: "ContainerBuild", dependencies: [ diff --git a/README.md b/README.md index bcc88bd34..3ac48079c 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,10 @@ The tool consumes and produces [OCI-compatible container images](https://github. `container` uses the [Containerization](https://github.com/apple/containerization) Swift package for low level container, image, and process management. +The CLI also includes an MVP `container compose` workflow for running common multi-service development setups from `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml` files. + +For a short implementation and design summary of the feature, see the [Compose feature brief](./docs/compose-feature-brief.md). + ![introductory movie showing some basic commands](./docs/assets/landing-movie.gif) ## Get started @@ -80,6 +84,8 @@ To retain your user data so that it is available should you reinstall later, run - Learn how to [use various `container` features](./docs/how-to.md). - Read a brief description and [technical overview](./docs/technical-overview.md) of `container`. - Browse the [full command reference](./docs/command-reference.md). +- Use [`container compose`](./docs/command-reference.md#container-compose) for supported Compose-based local development workflows. +- Read the [Compose feature brief](./docs/compose-feature-brief.md) for implementation notes and current limits. - [Build and run](./BUILDING.md) `container` on your own development system. - View the project [API documentation](https://apple.github.io/container/documentation/). diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 57dea438d..63585f5e8 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -75,6 +75,12 @@ public struct Application: AsyncLoggableCommand { RegistryCommand.self, ] ), + CommandGroup( + name: "Compose", + subcommands: [ + ComposeCommand.self + ] + ), CommandGroup( name: "Volume", subcommands: [ diff --git a/Sources/ContainerCommands/Compose/ComposeCommand.swift b/Sources/ContainerCommands/Compose/ComposeCommand.swift new file mode 100644 index 000000000..5658cd13b --- /dev/null +++ b/Sources/ContainerCommands/Compose/ComposeCommand.swift @@ -0,0 +1,741 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import ContainerResource +import ContainerizationError +import Darwin +import Foundation +import Logging +import Yams + +extension Application { + public struct ComposeCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "compose", + abstract: "Manage multi-container applications from Compose files", + subcommands: [ + ComposeConfig.self, + ComposeUp.self, + ComposeDown.self, + ComposePs.self, + ComposeLogs.self, + ] + ) + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + print(Self.helpMessage()) + } + } +} + +private protocol ComposeFileOptions { + var file: String? { get } + var projectName: String? { get } + var profiles: [String] { get } + var envFiles: [String] { get } +} + +private extension ComposeFileOptions { + func loadComposeProject() throws -> ComposeProject { + try ComposeSupport.loadProject( + filePath: file, + projectName: projectName, + profiles: profiles, + envFiles: envFiles + ) + } +} + +extension Application.ComposeCommand { + struct ComposeConfig: AsyncLoggableCommand, ComposeFileOptions { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "Validate and print the normalized Compose project" + ) + + @Option(name: .shortAndLong, help: "Compose file path") + var file: String? + + @Option(name: .shortAndLong, help: "Compose project name") + var projectName: String? + + @Option(name: .customLong("profile"), help: "Enable a Compose profile") + var profiles: [String] = [] + + @Option(name: .customLong("env-file"), help: "Compose interpolation env-file") + var envFiles: [String] = [] + + @OptionGroup + public var logOptions: Flags.Logging + + func run() async throws { + let project = try loadComposeProject() + let encoder = YAMLEncoder() + let output = try encoder.encode(RenderedComposeProject(project: project)) + print(output.trimmingCharacters(in: .whitespacesAndNewlines)) + } + } + + struct ComposeUp: AsyncLoggableCommand, ComposeFileOptions { + static let configuration = CommandConfiguration( + commandName: "up", + abstract: "Create and start services from a Compose project" + ) + + @Option(name: .shortAndLong, help: "Compose file path") + var file: String? + + @Option(name: .shortAndLong, help: "Compose project name") + var projectName: String? + + @Option(name: .customLong("profile"), help: "Enable a Compose profile") + var profiles: [String] = [] + + @Option(name: .customLong("env-file"), help: "Compose interpolation env-file") + var envFiles: [String] = [] + + @Flag(name: .shortAndLong, help: "Build images before starting services") + var build: Bool = false + + @Argument(help: "Optional service names to start") + var serviceNames: [String] = [] + + @OptionGroup + public var logOptions: Flags.Logging + + func run() async throws { + let project = try loadComposeProject() + try await ComposeExecutor(log: log).up(project: project, selectedServices: Set(serviceNames), build: build) + } + } + + struct ComposeDown: AsyncLoggableCommand, ComposeFileOptions { + static let configuration = CommandConfiguration( + commandName: "down", + abstract: "Stop and remove Compose project resources" + ) + + @Option(name: .shortAndLong, help: "Compose file path") + var file: String? + + @Option(name: .shortAndLong, help: "Compose project name") + var projectName: String? + + @Option(name: .customLong("profile"), help: "Enable a Compose profile") + var profiles: [String] = [] + + @Option(name: .customLong("env-file"), help: "Compose interpolation env-file") + var envFiles: [String] = [] + + @Flag(name: [.customShort("v"), .customLong("volumes")], help: "Remove named volumes declared by the Compose project") + var removeVolumes: Bool = false + + @Flag(name: .customLong("remove-orphans"), help: "Accepted for compatibility; project-scoped resources are always removed") + var removeOrphans: Bool = false + + @OptionGroup + public var logOptions: Flags.Logging + + func run() async throws { + let project = try loadComposeProject() + try await ComposeExecutor(log: log).down(project: project, removeVolumes: removeVolumes || removeOrphans) + } + } + + struct ComposePs: AsyncLoggableCommand, ComposeFileOptions { + static let configuration = CommandConfiguration( + commandName: "ps", + abstract: "List Compose services and their containers" + ) + + @Option(name: .shortAndLong, help: "Compose file path") + var file: String? + + @Option(name: .shortAndLong, help: "Compose project name") + var projectName: String? + + @Option(name: .customLong("profile"), help: "Enable a Compose profile") + var profiles: [String] = [] + + @Option(name: .customLong("env-file"), help: "Compose interpolation env-file") + var envFiles: [String] = [] + + @Option(name: .long, help: "Format of the output") + var format: Application.ListFormat = .table + + @Argument(help: "Optional service names to filter") + var serviceNames: [String] = [] + + @OptionGroup + public var logOptions: Flags.Logging + + func run() async throws { + let project = try loadComposeProject() + try await ComposeExecutor(log: log).ps(project: project, selectedServices: Set(serviceNames), format: format) + } + } + + struct ComposeLogs: AsyncLoggableCommand, ComposeFileOptions { + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Show logs for Compose services" + ) + + @Option(name: .shortAndLong, help: "Compose file path") + var file: String? + + @Option(name: .shortAndLong, help: "Compose project name") + var projectName: String? + + @Option(name: .customLong("profile"), help: "Enable a Compose profile") + var profiles: [String] = [] + + @Option(name: .customLong("env-file"), help: "Compose interpolation env-file") + var envFiles: [String] = [] + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Option(name: .short, help: "Number of lines to show from the end of the logs") + var numLines: Int? + + @Argument(help: "Optional service names to show") + var serviceNames: [String] = [] + + @OptionGroup + public var logOptions: Flags.Logging + + func run() async throws { + let project = try loadComposeProject() + try await ComposeExecutor(log: log).logs( + project: project, + selectedServices: Set(serviceNames), + follow: follow, + numLines: numLines + ) + } + } +} + +private struct ComposeExecutor { + let log: Logger + + func up(project: ComposeProject, selectedServices: Set, build: Bool) async throws { + let services = try filteredOrderedServices(project: project, selectedServices: selectedServices) + + try await ensureNetworks(project: project) + try await ensureVolumes(project: project) + + if build { + for service in services where service.build != nil { + try await buildImage(for: service) + } + } + + let client = ContainerClient() + let existingContainers = try await client.list(filters: ContainerListFilters(labels: [ + ComposeSupport.projectLabel: project.name + ])) + let existingById = Dictionary(uniqueKeysWithValues: existingContainers.map { ($0.id, $0) }) + + for service in services { + try await waitForDependencies(of: service, in: project) + let configHash = try ComposeSupport.hashService(service) + let existing = existingById[service.containerName] + + if let existing, existing.configuration.labels[ComposeSupport.configHashLabel] != configHash { + if existing.status == .running || existing.status == .stopping { + try await client.stop(id: existing.id) + } + try await client.delete(id: existing.id, force: true) + } + + if let existing = try? await client.get(id: service.containerName), + existing.configuration.labels[ComposeSupport.configHashLabel] == configHash { + if existing.status == .running { + continue + } + try await startDetached(containerId: existing.id) + continue + } + + try await createAndStart(service: service, project: project, configHash: configHash) + } + } + + func down(project: ComposeProject, removeVolumes: Bool) async throws { + let client = ContainerClient() + let containers = try await client.list(filters: ContainerListFilters(labels: [ + ComposeSupport.projectLabel: project.name + ])) + + for container in containers where container.status == .running || container.status == .stopping { + try await client.stop(id: container.id) + } + for container in containers { + try await client.delete(id: container.id, force: true) + } + + let networks = try await ClientNetwork.list() + for network in networks where networkProjectName(network) == project.name { + try? await ClientNetwork.delete(id: network.id) + } + + if removeVolumes { + let volumes = try await ClientVolume.list() + for volume in volumes where volume.labels[ComposeSupport.projectLabel] == project.name { + try? await ClientVolume.delete(name: volume.name) + } + } + } + + func ps(project: ComposeProject, selectedServices: Set, format: Application.ListFormat) async throws { + let client = ContainerClient() + let containers = try await client.list(filters: ContainerListFilters(labels: [ + ComposeSupport.projectLabel: project.name + ])) + let filtered = containers + .filter { selectedServices.isEmpty || selectedServices.contains($0.configuration.labels[ComposeSupport.serviceLabel] ?? "") } + .sorted { + ($0.configuration.labels[ComposeSupport.serviceLabel] ?? "", $0.id) + < ($1.configuration.labels[ComposeSupport.serviceLabel] ?? "", $1.id) + } + + if format == .json { + let data = try JSONEncoder().encode(filtered.map(PrintableComposeService.init)) + print(String(decoding: data, as: UTF8.self)) + return + } + + var rows = [["SERVICE", "CONTAINER", "STATE", "IMAGE", "STARTED"]] + for container in filtered { + rows.append([ + container.configuration.labels[ComposeSupport.serviceLabel] ?? "", + container.id, + container.status.rawValue, + container.configuration.image.reference, + container.startedDate.map { ISO8601DateFormatter().string(from: $0) } ?? "", + ]) + } + print(TableOutput(rows: rows).format()) + } + + func logs(project: ComposeProject, selectedServices: Set, follow: Bool, numLines: Int?) async throws { + let client = ContainerClient() + let containers = try await client.list(filters: ContainerListFilters(labels: [ + ComposeSupport.projectLabel: project.name + ])) + let filtered = containers + .filter { selectedServices.isEmpty || selectedServices.contains($0.configuration.labels[ComposeSupport.serviceLabel] ?? "") } + .sorted { + ($0.configuration.labels[ComposeSupport.serviceLabel] ?? "", $0.id) + < ($1.configuration.labels[ComposeSupport.serviceLabel] ?? "", $1.id) + } + + if follow && filtered.count > 1 { + throw ContainerizationError(.unsupported, message: "following logs for multiple Compose services is not supported") + } + + for container in filtered { + let fhs = try await client.logs(id: container.id) + let serviceName = container.configuration.labels[ComposeSupport.serviceLabel] ?? container.id + try printLogData( + try fhs[0].readToEnd() ?? Data(), + prefix: filtered.count > 1 ? "\(serviceName) | " : "", + numLines: numLines + ) + + if follow { + try await followLog(fhs[0], prefix: filtered.count > 1 ? "\(serviceName) | " : "") + } + } + } + + private func filteredOrderedServices(project: ComposeProject, selectedServices: Set) throws -> [NormalizedComposeService] { + let ordered = try project.orderedServices + if selectedServices.isEmpty { + return ordered + } + let expanded = try dependencyClosure(project: project, selectedServices: selectedServices) + return ordered.filter { expanded.contains($0.name) } + } + + private func dependencyClosure(project: ComposeProject, selectedServices: Set) throws -> Set { + var result = selectedServices + var stack = Array(selectedServices) + while let name = stack.popLast() { + guard let service = project.services[name] else { + throw ContainerizationError(.invalidArgument, message: "unknown Compose service '\(name)'") + } + for dependency in service.dependsOn where !result.contains(dependency) { + result.insert(dependency) + stack.append(dependency) + } + } + return result + } + + private func ensureNetworks(project: ComposeProject) async throws { + let existing = try await ClientNetwork.list() + let existingNames = Set(existing.map(\.id)) + for networkName in project.networks where !existingNames.contains(networkName) { + let config = try NetworkConfiguration( + id: networkName, + mode: .nat, + labels: ComposeSupport.projectResourceLabels(project: project.name), + pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet") + ) + _ = try await ClientNetwork.create(configuration: config) + } + } + + private func ensureVolumes(project: ComposeProject) async throws { + let existing = try await ClientVolume.list() + let existingNames = Set(existing.map(\.name)) + for volumeName in project.volumes where !existingNames.contains(volumeName) { + _ = try await ClientVolume.create( + name: volumeName, + labels: ComposeSupport.projectResourceLabels(project: project.name) + ) + } + } + + private func buildImage(for service: NormalizedComposeService) async throws { + guard let build = service.build else { return } + var arguments = ["--progress", "plain", "--tag", service.image] + for key in build.args.keys.sorted() { + arguments.append(contentsOf: ["--build-arg", "\(key)=\(build.args[key] ?? "")"]) + } + if let dockerfilePath = build.dockerfilePath { + arguments.append(contentsOf: ["--file", dockerfilePath]) + } + if let target = build.target { + arguments.append(contentsOf: ["--target", target]) + } + arguments.append(build.contextDirectory) + var command = try Application.BuildCommand.parseAsRoot(arguments) + try await command.run() + } + + private func waitForDependencies(of service: NormalizedComposeService, in project: ComposeProject) async throws { + for dependency in service.dependencyConditions { + guard let dependencyService = project.services[dependency.service] else { + throw ContainerizationError(.invalidArgument, message: "service '\(service.name)' depends on unknown service '\(dependency.service)'") + } + + switch dependency.condition { + case .serviceStarted: + try await waitForContainerRunning(id: dependencyService.containerName) + case .serviceHealthy: + guard let healthcheck = dependencyService.healthcheck else { + throw ContainerizationError( + .invalidArgument, + message: "service '\(service.name)' requires '\(dependency.service)' to be healthy, but '\(dependency.service)' has no healthcheck" + ) + } + try await waitForHealthcheck(service: dependencyService, healthcheck: healthcheck) + } + } + } + + private func createAndStart(service: NormalizedComposeService, project: ComposeProject, configHash: String) async throws { + let processFlags = Flags.Process( + cwd: service.workingDir, + env: service.environment, + envFile: [], + gid: nil, + interactive: service.stdinOpen, + tty: service.tty, + uid: nil, + ulimits: [], + user: service.user + ) + + let managementFlags = Flags.Management( + arch: Arch.hostArchitecture().rawValue, + cidfile: "", + detach: true, + dns: Flags.DNS(domain: nil, nameservers: [], options: [], searchDomains: []), + dnsDisabled: false, + entrypoint: service.entrypoint, + initImage: nil, + kernel: nil, + labels: try serviceLabels(service: service, projectName: project.name, configHash: configHash), + mounts: [], + name: service.containerName, + networks: service.networks, + os: "linux", + platform: nil, + publishPorts: service.ports, + publishSockets: [], + readOnly: false, + remove: false, + rosetta: false, + runtime: nil, + ssh: false, + tmpFs: [], + useInit: false, + virtualization: false, + volumes: service.volumes + ) + + let registryFlags = Flags.Registry(scheme: "auto") + let resourceFlags = Flags.Resource(cpus: nil, memory: nil) + let imageFetchFlags = Flags.ImageFetch(maxConcurrentDownloads: 3) + let client = ContainerClient() + let ck = try await Utility.containerConfigFromFlags( + id: service.containerName, + image: service.image, + arguments: service.commandArguments, + process: processFlags, + management: managementFlags, + resource: resourceFlags, + registry: registryFlags, + imageFetch: imageFetchFlags, + progressUpdate: { _ in }, + log: log + ) + + var configuration = ck.0 + configuration.networks = configuration.networks.map { + AttachmentConfiguration( + network: $0.network, + options: AttachmentOptions( + hostname: service.name, + macAddress: $0.options.macAddress, + mtu: $0.options.mtu + ) + ) + } + + try await client.create( + configuration: configuration, + options: ContainerCreateOptions(autoRemove: false), + kernel: ck.1, + initImage: ck.2 + ) + try await startDetached(containerId: service.containerName) + } + + private func serviceLabels(service: NormalizedComposeService, projectName: String, configHash: String) throws -> [String] { + let labels = ComposeSupport.composeLabels(project: projectName, service: service.name, configHash: configHash) + return labels.keys.sorted().map { "\($0)=\(labels[$0] ?? "")" } + } + + private func startDetached(containerId: String) async throws { + let client = ContainerClient() + let container = try await client.get(id: containerId) + + if container.status == .running { + print(containerId) + return + } + + for mount in container.configuration.mounts where mount.isVirtiofs { + if !FileManager.default.fileExists(atPath: mount.source) { + throw ContainerizationError(.invalidState, message: "path '\(mount.source)' is not a directory") + } + } + + let io = try ProcessIO.create( + tty: container.configuration.initProcess.terminal, + interactive: false, + detach: true + ) + defer { + try? io.close() + } + + do { + let process = try await client.bootstrap(id: container.id, stdio: io.stdio) + try await process.start() + try io.closeAfterStart() + print(containerId) + } catch { + try? await client.stop(id: container.id) + + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start container: \(error)") + } + } + + private func networkProjectName(_ network: NetworkState) -> String? { + switch network { + case .created(let config), .running(let config, _): + return config.labels[ComposeSupport.projectLabel] + } + } + + private func waitForContainerRunning(id: String, timeout: Duration = .seconds(60)) async throws { + let client = ContainerClient() + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if let container = try? await client.get(id: id), container.status == .running { + return + } + try await Task.sleep(for: .milliseconds(500)) + } + throw ContainerizationError(.timeout, message: "timed out waiting for container \(id) to start") + } + + private func waitForHealthcheck(service: NormalizedComposeService, healthcheck: ComposeHealthcheckDefinition) async throws { + guard case .none = healthcheck.test else { + try await waitForContainerRunning(id: service.containerName) + if healthcheck.startPeriod > .zero { + try await Task.sleep(for: healthcheck.startPeriod) + } + + let attempts = max(healthcheck.retries, 1) + for attempt in 1...attempts { + if try await runHealthcheckProbe(containerId: service.containerName, healthcheck: healthcheck) { + return + } + if attempt < attempts { + try await Task.sleep(for: healthcheck.interval) + } + } + + throw ContainerizationError( + .invalidState, + message: "healthcheck failed for service '\(service.name)' after \(attempts) attempt(s)" + ) + } + + throw ContainerizationError(.invalidArgument, message: "service '\(service.name)' disables its healthcheck and can not satisfy service_healthy") + } + + private func runHealthcheckProbe(containerId: String, healthcheck: ComposeHealthcheckDefinition) async throws -> Bool { + let client = ContainerClient() + let container = try await client.get(id: containerId) + try Application.ensureRunning(container: container) + + let command: [String] + switch healthcheck.test { + case .none: + return false + case .shell(let shellCommand): + command = ["/bin/sh", "-c", shellCommand] + case .exec(let args): + guard !args.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "healthcheck command for container \(containerId) is empty") + } + command = args + } + + var config = container.configuration.initProcess + config.executable = command[0] + config.arguments = Array(command.dropFirst()) + config.terminal = false + + let process = try await client.createProcess( + containerId: container.id, + processId: UUID().uuidString.lowercased(), + configuration: config, + stdio: [nil, nil, nil] + ) + try await process.start() + + return try await withThrowingTaskGroup(of: Bool.self) { group in + group.addTask { + let exitCode = try await process.wait() + return exitCode == 0 + } + group.addTask { + try await Task.sleep(for: healthcheck.timeout) + try? await process.kill(SIGKILL) + return false + } + + let result = try await group.next() ?? false + group.cancelAll() + return result + } + } + + private func printLogData(_ data: Data, prefix: String, numLines: Int?) throws { + guard let text = String(data: data, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to decode container logs as UTF-8") + } + var lines = text.components(separatedBy: .newlines).filter { !$0.isEmpty } + if let numLines { + lines = Array(lines.suffix(numLines)) + } + for line in lines { + print("\(prefix)\(line)") + } + } + + private func followLog(_ handle: FileHandle, prefix: String) async throws { + _ = try handle.seekToEnd() + let stream = AsyncStream { 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) + } + } + } + } + + for await line in stream { + print("\(prefix)\(line)") + } + } +} + +private struct RenderedComposeProject: Encodable { + let name: String + let services: [String: NormalizedComposeService] + let networks: [String] + let volumes: [String] + + init(project: ComposeProject) { + self.name = project.name + self.services = project.services + self.networks = project.networks + self.volumes = project.volumes + } +} + +private struct PrintableComposeService: Codable { + let service: String + let container: String + let state: RuntimeStatus + let image: String + let startedDate: Date? + + init(_ snapshot: ContainerSnapshot) { + self.service = snapshot.configuration.labels[ComposeSupport.serviceLabel] ?? snapshot.id + self.container = snapshot.id + self.state = snapshot.status + self.image = snapshot.configuration.image.reference + self.startedDate = snapshot.startedDate + } +} diff --git a/Sources/ContainerCommands/Compose/ComposeSupport.swift b/Sources/ContainerCommands/Compose/ComposeSupport.swift new file mode 100644 index 000000000..0d76fbb95 --- /dev/null +++ b/Sources/ContainerCommands/Compose/ComposeSupport.swift @@ -0,0 +1,1107 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerAPIClient +import ContainerResource +import ContainerizationError +import CryptoKit +import Foundation +import Yams + +enum ComposeSupport { + static let defaultFileNames = [ + "compose.yaml", + "compose.yml", + "docker-compose.yaml", + "docker-compose.yml", + ] + + static let projectLabel = "com.apple.container.compose.project" + static let serviceLabel = "com.apple.container.compose.service" + static let versionLabel = "com.apple.container.compose.version" + static let configHashLabel = "com.apple.container.compose.config-hash" + static let managedVersion = "mvp" + + static let managedContainerLabels = [ + versionLabel: managedVersion + ] + + static func discoveredComposeFile(in directory: URL) throws -> URL { + for name in defaultFileNames { + let candidate = directory.appendingPathComponent(name) + if FileManager.default.fileExists(atPath: candidate.path(percentEncoded: false)) { + return candidate + } + } + throw ContainerizationError( + .notFound, + message: "no Compose file found. Looked for: \(defaultFileNames.joined(separator: ", "))" + ) + } + + static func resolveComposeFile(input: String?) throws -> URL { + if let input, !input.isEmpty { + let url = URL(fileURLWithPath: input, relativeTo: .currentDirectory()).standardizedFileURL + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { + throw ContainerizationError(.notFound, message: "Compose file not found at \(url.path(percentEncoded: false))") + } + return url + } + return try discoveredComposeFile(in: URL.currentDirectory()) + } + + static func loadProject( + filePath: String?, + projectName: String?, + profiles: [String], + envFiles: [String] + ) throws -> ComposeProject { + let fileURL = try resolveComposeFile(input: filePath) + let projectDirectory = fileURL.deletingLastPathComponent() + let rawData = try Data(contentsOf: fileURL) + guard let rawText = String(data: rawData, encoding: .utf8) else { + throw ContainerizationError(.invalidArgument, message: "Compose file contains invalid UTF-8") + } + + try validateRawComposeYaml(rawText) + + let interpolationEnv = try interpolationEnvironment( + projectDirectory: projectDirectory, + explicitEnvFiles: envFiles + ) + let interpolated = try interpolate(rawText, environment: interpolationEnv) + + let decoder = YAMLDecoder() + let definition = try decoder.decode(ComposeFileDefinition.self, from: interpolated) + return try ComposeProject( + definition: definition, + fileURL: fileURL, + explicitProjectName: projectName, + selectedProfiles: Set(profiles) + ) + } + + static func interpolationEnvironment( + projectDirectory: URL, + explicitEnvFiles: [String] + ) throws -> [String: String] { + 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 } + } + + for envFile in explicitEnvFiles { + let envURL = URL(fileURLWithPath: envFile, relativeTo: .currentDirectory()).standardizedFileURL + merged.merge(try parseEnvFile(envURL)) { _, new in new } + } + + return merged + } + + static func parseEnvFile(_ url: URL) throws -> [String: String] { + let lines = try Parser.envFile(path: url.path(percentEncoded: false)) + var result: [String: String] = [:] + for line in lines { + let parts = line.split(separator: "=", maxSplits: 1) + let key = String(parts[0]) + let value = parts.count == 2 ? String(parts[1]) : "" + result[key] = value + } + return result + } + + static func interpolate(_ input: String, environment: [String: String]) throws -> String { + var output = "" + var index = input.startIndex + + func readVariableName(from start: String.Index) -> (String, String.Index) { + var cursor = start + while cursor < input.endIndex { + let ch = input[cursor] + if ch.isLetter || ch.isNumber || ch == "_" { + cursor = input.index(after: cursor) + } else { + break + } + } + return (String(input[start.., allowed: Set, path: String) throws { + let unsupported = actual.subtracting(allowed) + guard unsupported.isEmpty else { + let key = unsupported.sorted().joined(separator: ", ") + throw ContainerizationError(.unsupported, message: "unsupported Compose keys at \(path): \(key)") + } + } + + static func hashService(_ service: NormalizedComposeService) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(service) + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } + + static func composeLabels(project: String, service: String, configHash: String) -> [String: String] { + managedContainerLabels.merging([ + projectLabel: project, + serviceLabel: service, + configHashLabel: configHash, + ]) { _, new in new } + } + + static func projectResourceLabels(project: String) -> [String: String] { + [ + projectLabel: project, + versionLabel: managedVersion, + ] + } + + static func topologicalServices(_ services: [String: NormalizedComposeService]) throws -> [NormalizedComposeService] { + var ordered: [NormalizedComposeService] = [] + var visiting = Set() + var visited = Set() + + func visit(_ name: String) throws { + guard !visited.contains(name) else { return } + if visiting.contains(name) { + throw ContainerizationError(.invalidArgument, message: "circular depends_on graph involving service '\(name)'") + } + guard let service = services[name] else { + throw ContainerizationError(.invalidArgument, message: "service '\(name)' not found in project") + } + visiting.insert(name) + for dependency in service.dependsOn { + guard services[dependency] != nil else { + throw ContainerizationError(.invalidArgument, message: "service '\(name)' depends on missing service '\(dependency)'") + } + try visit(dependency) + } + visiting.remove(name) + visited.insert(name) + ordered.append(service) + } + + for name in services.keys.sorted() { + try visit(name) + } + return ordered + } +} + +struct ComposeFileDefinition: Decodable { + var version: String? + var name: String? + var services: [String: ComposeServiceDefinition] + var networks: [String: ComposeNamedNetwork] = [:] + var volumes: [String: ComposeNamedVolume] = [:] + + enum CodingKeys: String, CodingKey { + case version + case name + case services + case networks + case volumes + } + + init(from decoder: Decoder) throws { + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.version = try keyed.decodeIfPresent(String.self, forKey: .version) + self.name = try keyed.decodeIfPresent(String.self, forKey: .name) + self.services = try keyed.decode([String: ComposeServiceDefinition].self, forKey: .services) + self.networks = try keyed.decodeIfPresent([String: ComposeNamedNetwork].self, forKey: .networks) ?? [:] + self.volumes = try keyed.decodeIfPresent([String: ComposeNamedVolume].self, forKey: .volumes) ?? [:] + } +} + +struct ComposeNamedNetwork: Decodable {} + +struct ComposeNamedVolume: Decodable {} + +struct ComposeBuildDefinition: Codable, Sendable { + var context: String + var dockerfile: String? + var args: [String: String] + var target: String? + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let context = try? container.decode(String.self) { + self.context = context + self.dockerfile = nil + self.args = [:] + self.target = nil + return + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.context = try keyed.decode(String.self, forKey: .context) + self.dockerfile = try keyed.decodeIfPresent(String.self, forKey: .dockerfile) + self.args = try keyed.decodeIfPresent([String: String].self, forKey: .args) ?? [:] + self.target = try keyed.decodeIfPresent(String.self, forKey: .target) + } +} + +enum ComposeDependencyCondition: String, Codable, Sendable { + case serviceStarted = "service_started" + case serviceHealthy = "service_healthy" +} + +struct ComposeDependencyOptions: Decodable, Sendable { + var condition: ComposeDependencyCondition = .serviceStarted +} + +enum ComposeHealthcheckTest: Codable, Sendable { + case none + case shell(String) + case exec([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let raw = try? container.decode(String.self) { + if raw == "NONE" { + self = .none + } else { + self = .shell(raw) + } + return + } + + let values = try container.decode([String].self) + if values == ["NONE"] { + self = .none + return + } + if let first = values.first, first == "CMD-SHELL" { + self = .shell(values.dropFirst().joined(separator: " ")) + return + } + if let first = values.first, first == "CMD" { + self = .exec(Array(values.dropFirst())) + return + } + self = .exec(values) + } +} + +struct ComposeHealthcheckDefinition: Codable, Sendable { + var test: ComposeHealthcheckTest + var interval: Duration + var timeout: Duration + var retries: Int + var startPeriod: Duration + + enum CodingKeys: String, CodingKey { + case test + case interval + case timeout + case retries + case startPeriod = "start_period" + } + + init(from decoder: Decoder) throws { + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.test = try keyed.decode(ComposeHealthcheckTest.self, forKey: .test) + self.interval = try keyed.decodeIfPresent(ComposeDuration.self, forKey: .interval)?.duration ?? .seconds(30) + self.timeout = try keyed.decodeIfPresent(ComposeDuration.self, forKey: .timeout)?.duration ?? .seconds(30) + self.retries = try keyed.decodeIfPresent(Int.self, forKey: .retries) ?? 3 + self.startPeriod = try keyed.decodeIfPresent(ComposeDuration.self, forKey: .startPeriod)?.duration ?? .zero + } +} + +private struct ComposeDuration: Decodable { + let duration: Duration + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let seconds = try? container.decode(Double.self) { + self.duration = .milliseconds(Int64(seconds * 1000)) + return + } + let raw = try container.decode(String.self) + self.duration = try Self.parse(raw) + } + + private static func parse(_ raw: String) throws -> Duration { + let pattern = #/^(\d+)(ms|s|m|h)$/# + guard let match = raw.wholeMatch(of: pattern) else { + throw ContainerizationError(.invalidArgument, message: "unsupported Compose duration '\(raw)'") + } + let magnitude = Int64(match.1) ?? 0 + switch String(match.2) { + case "ms": + return .milliseconds(magnitude) + case "s": + return .seconds(magnitude) + case "m": + return .seconds(magnitude * 60) + case "h": + return .seconds(magnitude * 3600) + default: + throw ContainerizationError(.invalidArgument, message: "unsupported Compose duration '\(raw)'") + } + } +} + +enum ComposeShellCommand: Codable, Sendable { + case string(String) + case array([String]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .string(string) + return + } + self = .array(try container.decode([String].self)) + } + + func flattenedForExecution() throws -> [String] { + switch self { + case .string(let value): + return try Self.tokenize(value) + case .array(let values): + return values + } + } + + private static func tokenize(_ value: String) throws -> [String] { + enum State { + case unquoted + case singleQuoted + case doubleQuoted + } + + var state: State = .unquoted + var escapeNext = false + var tokenInProgress = false + var current = "" + var tokens: [String] = [] + + for character in value { + if escapeNext { + current.append(character) + tokenInProgress = true + escapeNext = false + continue + } + + switch state { + case .unquoted: + if character == "\\" { + escapeNext = true + } else if character == "'" { + state = .singleQuoted + tokenInProgress = true + } else if character == "\"" { + state = .doubleQuoted + tokenInProgress = true + } else if character.isWhitespace { + if tokenInProgress { + tokens.append(current) + current = "" + tokenInProgress = false + } + } else { + current.append(character) + tokenInProgress = true + } + case .singleQuoted: + if character == "'" { + state = .unquoted + } else { + current.append(character) + } + case .doubleQuoted: + if character == "\\" { + escapeNext = true + } else if character == "\"" { + state = .unquoted + } else { + current.append(character) + } + } + } + + if escapeNext || state != .unquoted { + throw ContainerizationError(.invalidArgument, message: "unterminated quoted command '\(value)'") + } + if tokenInProgress { + tokens.append(current) + } + return tokens + } +} + +enum ComposeEnvironment: Decodable { + case list([String]) + case dictionary([String: String?]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let list = try? container.decode([String].self) { + self = .list(list) + return + } + self = .dictionary(try container.decode([String: String?].self)) + } + + func resolvedEntries() -> [String] { + switch self { + case .list(let values): + return values + case .dictionary(let values): + return values.keys.sorted().map { key in + let value = values[key] ?? nil + return "\(key)=\(value ?? "")" + } + } + } +} + +enum ComposeDependsOn: Decodable { + case list([String]) + case map([String: ComposeDependencyOptions]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let list = try? container.decode([String].self) { + self = .list(list) + return + } + self = .map(try container.decode([String: ComposeDependencyOptions].self)) + } + + var services: [String] { + switch self { + case .list(let list): + return list + case .map(let map): + return map.keys.sorted() + } + } + + var dependencies: [String: ComposeDependencyCondition] { + switch self { + case .list(let list): + return Dictionary(uniqueKeysWithValues: list.map { ($0, .serviceStarted) }) + case .map(let map): + return map.mapValues(\.condition) + } + } +} + +struct EmptyComposeOptions: Decodable {} + +enum ComposeNetworks: Decodable { + case list([String]) + case map([String: EmptyComposeOptions]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let list = try? container.decode([String].self) { + self = .list(list) + return + } + self = .map(try container.decode([String: EmptyComposeOptions].self)) + } + + var names: [String] { + switch self { + case .list(let list): + return list + case .map(let map): + return map.keys.sorted() + } + } +} + +struct ComposePortDefinition: Codable, Sendable { + var hostIP: String? + var published: String? + var target: String + var protocolName: String? + var raw: String? + + enum CodingKeys: String, CodingKey { + case hostIP = "host_ip" + case published + case target + case protocolName = "protocol" + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let raw = try? container.decode(String.self) { + self.raw = raw + self.hostIP = nil + self.published = nil + self.target = "" + self.protocolName = nil + return + } + 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) + self.protocolName = try keyed.decodeIfPresent(String.self, forKey: .protocolName) + } + + func asPublishSpec() throws -> String { + if let raw { + return raw + } + guard let published, !published.isEmpty else { + throw ContainerizationError(.unsupported, message: "Compose ports without a published host port are not supported") + } + let proto = protocolName ?? "tcp" + if let hostIP, !hostIP.isEmpty { + return "\(hostIP):\(published):\(target)/\(proto)" + } + return "\(published):\(target)/\(proto)" + } +} + +struct ComposeVolumeDefinition: Codable, Sendable { + var raw: String? + var type: String? + var source: String? + var target: String? + var readOnly: Bool? + + enum CodingKeys: String, CodingKey { + case type + case source + case target + case readOnly = "read_only" + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let raw = try? container.decode(String.self) { + self.raw = raw + self.type = nil + self.source = nil + self.target = nil + self.readOnly = nil + return + } + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.raw = nil + self.type = try keyed.decodeIfPresent(String.self, forKey: .type) + self.source = try keyed.decodeIfPresent(String.self, forKey: .source) + self.target = try keyed.decodeIfPresent(String.self, forKey: .target) + self.readOnly = try keyed.decodeIfPresent(Bool.self, forKey: .readOnly) + } +} + +struct ComposeServiceDefinition: Decodable { + var image: String? + var build: ComposeBuildDefinition? + var command: ComposeShellCommand? + var entrypoint: ComposeShellCommand? + var environment: ComposeEnvironment? + var envFile: [String] + var ports: [ComposePortDefinition] + var volumes: [ComposeVolumeDefinition] + var dependsOn: [String] + var dependencyConditions: [String: ComposeDependencyCondition] + var networks: [String] + var workingDir: String? + var user: String? + var tty: Bool + var stdinOpen: Bool + var profiles: [String] + var healthcheck: ComposeHealthcheckDefinition? + + enum CodingKeys: String, CodingKey { + case image + case build + case command + case entrypoint + case environment + case envFile = "env_file" + case ports + case volumes + case dependsOn = "depends_on" + case networks + case workingDir = "working_dir" + case user + case tty + case stdinOpen = "stdin_open" + case profiles + case healthcheck + } + + init(from decoder: Decoder) throws { + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.image = try keyed.decodeIfPresent(String.self, forKey: .image) + self.build = try keyed.decodeIfPresent(ComposeBuildDefinition.self, forKey: .build) + self.command = try keyed.decodeIfPresent(ComposeShellCommand.self, forKey: .command) + self.entrypoint = try keyed.decodeIfPresent(ComposeShellCommand.self, forKey: .entrypoint) + self.environment = try keyed.decodeIfPresent(ComposeEnvironment.self, forKey: .environment) + if let envFiles = try? keyed.decode([String].self, forKey: .envFile) { + self.envFile = envFiles + } else if let envFile = try? keyed.decode(String.self, forKey: .envFile) { + self.envFile = [envFile] + } else { + self.envFile = [] + } + self.ports = try keyed.decodeIfPresent([ComposePortDefinition].self, forKey: .ports) ?? [] + self.volumes = try keyed.decodeIfPresent([ComposeVolumeDefinition].self, forKey: .volumes) ?? [] + let dependsOn = try keyed.decodeIfPresent(ComposeDependsOn.self, forKey: .dependsOn) + self.dependsOn = dependsOn?.services ?? [] + self.dependencyConditions = dependsOn?.dependencies ?? [:] + self.networks = (try keyed.decodeIfPresent(ComposeNetworks.self, forKey: .networks)?.names) ?? [] + self.workingDir = try keyed.decodeIfPresent(String.self, forKey: .workingDir) + self.user = try keyed.decodeIfPresent(String.self, forKey: .user) + self.tty = try keyed.decodeIfPresent(Bool.self, forKey: .tty) ?? false + self.stdinOpen = try keyed.decodeIfPresent(Bool.self, forKey: .stdinOpen) ?? false + self.profiles = try keyed.decodeIfPresent([String].self, forKey: .profiles) ?? [] + self.healthcheck = try keyed.decodeIfPresent(ComposeHealthcheckDefinition.self, forKey: .healthcheck) + } +} + +struct NormalizedComposeDependency: Codable, Sendable, Equatable { + var service: String + var condition: ComposeDependencyCondition +} + +struct NormalizedComposeBuild: Codable, Sendable { + var contextDirectory: String + var dockerfilePath: String? + var args: [String: String] + var target: String? +} + +struct NormalizedComposeService: Codable, Sendable { + var name: String + var containerName: String + var image: String + var build: NormalizedComposeBuild? + var commandArguments: [String] + var entrypoint: String? + var environment: [String] + var ports: [String] + var volumes: [String] + var dependsOn: [String] + var dependencyConditions: [NormalizedComposeDependency] + var networks: [String] + var workingDir: String? + var user: String? + var tty: Bool + var stdinOpen: Bool + var healthcheck: ComposeHealthcheckDefinition? +} + +struct ComposeProject: Codable, Sendable { + var fileURL: URL + var directoryURL: URL + var name: String + var services: [String: NormalizedComposeService] + var networks: [String] + var volumes: [String] + + init( + definition: ComposeFileDefinition, + fileURL: URL, + explicitProjectName: String?, + selectedProfiles: Set + ) throws { + let projectDirectory = fileURL.deletingLastPathComponent() + self.fileURL = fileURL + self.directoryURL = projectDirectory + + let baseName = explicitProjectName + ?? definition.name + ?? fileURL.deletingLastPathComponent().lastPathComponent + try Utility.validEntityName(baseName) + self.name = baseName + + let declaredNetworks = Set(definition.networks.keys) + let declaredVolumes = Set(definition.volumes.keys) + + var normalizedServices: [String: NormalizedComposeService] = [:] + var usedNetworks = Set() + var usedVolumes = Set() + + for serviceName in definition.services.keys.sorted() { + let service = definition.services[serviceName]! + if !selectedProfiles.isEmpty && !service.profiles.isEmpty && selectedProfiles.isDisjoint(with: Set(service.profiles)) { + continue + } + + guard service.image != nil || service.build != nil else { + throw ContainerizationError(.invalidArgument, message: "service '\(serviceName)' must define either image or build") + } + + let containerName = "\(baseName)-\(serviceName)" + try Utility.validEntityName(containerName) + + var build: NormalizedComposeBuild? + let image: String + if let buildDef = service.build { + let contextURL = URL(fileURLWithPath: buildDef.context, relativeTo: directoryURL).standardizedFileURL + let dockerfilePath = buildDef.dockerfile.map { dockerfile -> String in + URL(fileURLWithPath: dockerfile, relativeTo: contextURL).standardizedFileURL.path(percentEncoded: false) + } + build = .init( + contextDirectory: contextURL.path(percentEncoded: false), + dockerfilePath: dockerfilePath, + args: buildDef.args, + target: buildDef.target + ) + image = service.image ?? "\(baseName)-\(serviceName):compose" + } else { + image = service.image! + } + + let envFileEntries = try service.envFile.flatMap { envFile in + let envURL = URL(fileURLWithPath: envFile, relativeTo: projectDirectory).standardizedFileURL + return try Parser.envFile(path: envURL.path(percentEncoded: false)) + } + let inlineEnvironment = service.environment?.resolvedEntries() ?? [] + let environment = Self.mergeKeyValueEntries(envFileEntries + inlineEnvironment) + + let dependencyConditions = service.dependencyConditions.keys.sorted().map { + NormalizedComposeDependency(service: $0, condition: service.dependencyConditions[$0] ?? .serviceStarted) + } + + var commandArguments = try service.command?.flattenedForExecution() ?? [] + var entrypoint: String? + if let entrypointCommand = try service.entrypoint?.flattenedForExecution(), !entrypointCommand.isEmpty { + entrypoint = entrypointCommand.first + commandArguments = Array(entrypointCommand.dropFirst()) + commandArguments + } + + var serviceNetworks = service.networks + if serviceNetworks.isEmpty { + serviceNetworks = ["default"] + } + let resolvedNetworks = try serviceNetworks.map { networkName -> String in + if networkName == "default" { + return "\(baseName)_default" + } + guard declaredNetworks.contains(networkName) else { + throw ContainerizationError(.invalidArgument, message: "service '\(serviceName)' references undefined network '\(networkName)'") + } + return "\(baseName)_\(networkName)" + } + usedNetworks.formUnion(resolvedNetworks) + + let resolvedVolumes = try service.volumes.map { volume -> String in + if let raw = volume.raw { + return try Self.rewriteRawVolume(raw, declaredVolumes: declaredVolumes, projectName: baseName, relativeTo: projectDirectory, usedVolumes: &usedVolumes) + } + + guard volume.type == nil || volume.type == "bind" || volume.type == "volume" else { + throw ContainerizationError(.unsupported, message: "volume type '\(volume.type ?? "")' is not supported") + } + guard let source = volume.source, let target = volume.target else { + throw ContainerizationError(.invalidArgument, message: "volume entry for service '\(serviceName)' requires source and target") + } + + let readOnlySuffix = (volume.readOnly ?? false) ? ":ro" : "" + if volume.type == "bind" || source.contains("/") || source == "." || source == ".." { + let absoluteSource = URL(fileURLWithPath: source, relativeTo: projectDirectory).standardizedFileURL.path(percentEncoded: false) + return "\(absoluteSource):\(target)\(readOnlySuffix)" + } + + guard declaredVolumes.contains(source) else { + throw ContainerizationError(.invalidArgument, message: "service '\(serviceName)' references undefined volume '\(source)'") + } + let volumeName = "\(baseName)_\(source)" + usedVolumes.insert(volumeName) + return "\(volumeName):\(target)\(readOnlySuffix)" + } + + let resolvedPorts = try service.ports.map { try $0.asPublishSpec() } + + normalizedServices[serviceName] = NormalizedComposeService( + name: serviceName, + containerName: containerName, + image: image, + build: build, + commandArguments: commandArguments, + entrypoint: entrypoint, + environment: environment, + ports: resolvedPorts, + volumes: resolvedVolumes, + dependsOn: service.dependsOn, + dependencyConditions: dependencyConditions, + networks: resolvedNetworks, + workingDir: service.workingDir, + user: service.user, + tty: service.tty, + stdinOpen: service.stdinOpen, + healthcheck: service.healthcheck + ) + } + + self.services = normalizedServices + self.networks = usedNetworks.sorted() + self.volumes = usedVolumes.sorted() + } + + var orderedServices: [NormalizedComposeService] { + get throws { + try ComposeSupport.topologicalServices(services) + } + } + + func matchingContainers(_ containers: [ContainerSnapshot]) -> [String: ContainerSnapshot] { + Dictionary(uniqueKeysWithValues: containers.compactMap { container in + guard container.configuration.labels[ComposeSupport.projectLabel] == name else { + return nil + } + return (container.id, container) + }) + } + + private static 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] ?? "")" } + } + + private static func rewriteRawVolume( + _ raw: String, + declaredVolumes: Set, + projectName: String, + relativeTo baseURL: URL, + usedVolumes: inout Set + ) throws -> String { + let parts = raw.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + if parts.count == 1 { + return raw + } + guard parts.count == 2 || parts.count == 3 else { + throw ContainerizationError(.invalidArgument, message: "invalid volume definition: \(raw)") + } + let source = parts[0] + let destination = parts[1] + let options = parts.count == 3 ? ":\(parts[2])" : "" + + if source.contains("/") || source == "." || source == ".." { + let absoluteSource = URL(fileURLWithPath: source, relativeTo: baseURL).standardizedFileURL.path(percentEncoded: false) + return "\(absoluteSource):\(destination)\(options)" + } + + guard declaredVolumes.contains(source) else { + throw ContainerizationError(.invalidArgument, message: "undefined named volume '\(source)'") + } + + let projectVolume = "\(projectName)_\(source)" + usedVolumes.insert(projectVolume) + return "\(projectVolume):\(destination)\(options)" + } +} + +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] ?? "")" } +} + +private extension URL { + static func currentDirectory() -> URL { + URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + } +} diff --git a/Tests/CLITests/Subcommands/Compose/TestCLICompose.swift b/Tests/CLITests/Subcommands/Compose/TestCLICompose.swift new file mode 100644 index 000000000..e75783ce8 --- /dev/null +++ b/Tests/CLITests/Subcommands/Compose/TestCLICompose.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +final class TestCLICompose: CLITest { + @Test + func testComposeUpDoesNotTriggerParsableInitializationError() async throws { + try doPull(imageName: alpine) + + try await withTempDir { directory in + let projectName = testName.lowercased() + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + name: \(projectName) + services: + web: + image: \(alpine) + command: ["sleep", "infinity"] + """.write(to: composeURL, atomically: true, encoding: .utf8) + + defer { + _ = try? run(arguments: ["compose", "down"], currentDirectory: directory) + } + + let (_, _, error, status) = try run(arguments: ["compose", "up"], currentDirectory: directory) + #expect(status == 0, "expected compose up to succeed, stderr: \(error)") + #expect(!error.contains("Can't read a value from a parsable"), "unexpected parser initialization error: \(error)") + + try waitForContainerRunning("\(projectName)-web") + } + } + + @Test + func testComposeServicesResolveByServiceName() async throws { + try doPull(imageName: alpine) + + try await withTempDir { directory in + let projectName = testName.lowercased() + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + name: \(projectName) + services: + web: + image: \(alpine) + command: ["sleep", "infinity"] + probe: + image: \(alpine) + depends_on: + web: + condition: service_started + entrypoint: /bin/sh -c "nslookup web" + """.write(to: composeURL, atomically: true, encoding: .utf8) + + defer { + _ = try? run(arguments: ["compose", "down"], currentDirectory: directory) + } + + let (_, _, error, status) = try run(arguments: ["compose", "up"], currentDirectory: directory) + #expect(status == 0, "expected compose up to succeed, stderr: \(error)") + + try waitForContainerRunning("\(projectName)-web") + + let (_, output, logError, logStatus) = try run(arguments: ["logs", "\(projectName)-probe"]) + #expect(logStatus == 0, "expected probe logs to be readable, stderr: \(logError)") + #expect(output.contains("Name:") || output.contains("Address:"), "expected service-name DNS resolution in probe logs, got: \(output)") + } + } +} diff --git a/Tests/ContainerCommandsTests/ComposeSupportTests.swift b/Tests/ContainerCommandsTests/ComposeSupportTests.swift new file mode 100644 index 000000000..84a618658 --- /dev/null +++ b/Tests/ContainerCommandsTests/ComposeSupportTests.swift @@ -0,0 +1,229 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import Foundation +import Testing + +@testable import ContainerCommands + +struct ComposeSupportTests { + @Test + func discoversPreferredComposeFile() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let preferred = directory.appendingPathComponent("compose.yaml") + let fallback = directory.appendingPathComponent("docker-compose.yml") + try "services: {}\n".write(to: preferred, atomically: true, encoding: .utf8) + try "services: {}\n".write(to: fallback, atomically: true, encoding: .utf8) + + let discovered = try ComposeSupport.discoveredComposeFile(in: directory) + #expect(discovered.lastPathComponent == "compose.yaml") + } + + @Test + func interpolatesVariablesAndDefaults() throws { + let result = try ComposeSupport.interpolate( + """ + services: + web: + image: ${IMAGE_NAME:-nginx}:latest + environment: + FOO: $BAR + LIT: $$VALUE + """, + environment: [ + "BAR": "baz", + ] + ) + + #expect(result.contains("image: nginx:latest")) + #expect(result.contains("FOO: baz")) + #expect(result.contains("LIT: $VALUE")) + } + + @Test + func rejectsUnsupportedServiceKeys() throws { + #expect { + try ComposeSupport.validateRawComposeYaml( + """ + services: + web: + image: nginx + deploy: + replicas: 2 + """ + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("unsupported Compose keys") + } + } + + @Test + func normalizesProjectResourcesAndServiceOrder() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + name: sample + services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data + web: + image: nginx:latest + depends_on: + - db + networks: + - front + volumes: + - ./site:/usr/share/nginx/html:ro + volumes: + data: {} + networks: + front: {} + """.write(to: composeURL, atomically: true, encoding: .utf8) + + let project = try ComposeSupport.loadProject( + filePath: composeURL.path(percentEncoded: false), + projectName: nil, + profiles: [], + envFiles: [] + ) + + #expect(project.name == "sample") + #expect(project.networks == ["sample_default", "sample_front"]) + #expect(project.volumes == ["sample_data"]) + let orderedNames = try project.orderedServices.map(\.name) + #expect(orderedNames == ["db", "web"]) + #expect(project.services["db"]?.volumes == ["sample_data:/var/lib/postgresql/data"]) + #expect(project.services["db"]?.networks == ["sample_default"]) + #expect(project.services["web"]?.networks == ["sample_front"]) + } + + @Test + func filtersServicesByProfile() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + services: + web: + image: nginx:latest + admin: + image: nginx:latest + profiles: + - ops + """.write(to: composeURL, atomically: true, encoding: .utf8) + + let project = try ComposeSupport.loadProject( + filePath: composeURL.path(percentEncoded: false), + projectName: "demo", + profiles: [], + envFiles: [] + ) + #expect(project.services.keys.sorted() == ["admin", "web"]) + + let profiled = try ComposeSupport.loadProject( + filePath: composeURL.path(percentEncoded: false), + projectName: "demo", + profiles: ["web"], + envFiles: [] + ) + #expect(profiled.services.keys.sorted() == ["web"]) + } + + @Test + func acceptsHealthcheckAndDependencyCondition() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + services: + db: + image: postgres:16 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + api: + image: nginx:latest + depends_on: + db: + condition: service_healthy + """.write(to: composeURL, atomically: true, encoding: .utf8) + + let project = try ComposeSupport.loadProject( + filePath: composeURL.path(percentEncoded: false), + projectName: "demo", + profiles: [], + envFiles: [] + ) + + let db = try #require(project.services["db"]) + let api = try #require(project.services["api"]) + #expect(db.healthcheck != nil) + #expect(api.dependencyConditions == [ + NormalizedComposeDependency(service: "db", condition: .serviceHealthy) + ]) + } + + @Test + func tokenizesStringCommandAndEntrypointWithoutImplicitShellWrapping() throws { + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let composeURL = directory.appendingPathComponent("compose.yaml") + try """ + services: + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + setup: + image: minio/mc:latest + entrypoint: /bin/sh -c "echo hello world" + """.write(to: composeURL, atomically: true, encoding: .utf8) + + let project = try ComposeSupport.loadProject( + filePath: composeURL.path(percentEncoded: false), + projectName: "demo", + profiles: [], + envFiles: [] + ) + + let minio = try #require(project.services["minio"]) + let setup = try #require(project.services["setup"]) + #expect(minio.entrypoint == nil) + #expect(minio.commandArguments == ["server", "/data", "--console-address", ":9001"]) + #expect(setup.entrypoint == "/bin/sh") + #expect(setup.commandArguments == ["-c", "echo hello world"]) + } + + private func makeTempDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 6bd108754..248267d13 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -9,6 +9,65 @@ Note: Command availability may vary depending on host operating system and macOS ## Core Commands +### `container compose` + +Runs supported Docker Compose style workflows directly from the `container` CLI. The MVP implementation supports these default filenames in discovery order: + +```bash +compose.yaml +compose.yml +docker-compose.yaml +docker-compose.yml +``` + +The command currently targets common local-development Compose files. Unsupported keys fail validation instead of being ignored. + +**Usage** + +```bash +container compose [] +``` + +**Subcommands** + +* `container compose config`: Validate a Compose file and print the normalized project +* `container compose up`: Build, create, and start services +* `container compose down`: Stop and remove project containers, and optionally project volumes +* `container compose ps`: List project services and backing containers +* `container compose logs`: Print logs for project services + +**Shared Options** + +* `-f, --file `: Path to the Compose file +* `-p, --project-name `: Override the Compose project name +* `--profile `: Enable a Compose profile (repeatable) +* `--env-file `: Additional env-file for Compose interpolation (repeatable) + +**Supported Compose Fields** + +* Top-level: `name`, `services`, `networks`, `volumes` +* Service: `image`, `build.context`, `build.dockerfile`, `build.args`, `build.target`, `command`, `entrypoint`, `environment`, `env_file`, `ports`, `volumes`, `depends_on`, `depends_on.condition`, `networks`, `working_dir`, `user`, `tty`, `stdin_open`, `profiles`, `healthcheck` + +**Unsupported Compose Fields** + +The following currently fail validation: `deploy`, `secrets`, `configs`, `extends`, `develop`, `container_name`, replica/scaling settings, and `depends_on.condition=service_completed_successfully`. + +**Examples** + +```bash +# validate and print the normalized project +container compose config + +# build and start the project discovered from compose.yaml +container compose up --build + +# follow logs for a single service +container compose logs --follow web + +# stop the project and remove named volumes created for it +container compose down --volumes +``` + ### `container run` Runs a container from an image. If a command is provided, it will execute inside the container; otherwise the image's default command runs. By default the container runs in the foreground and stdin remains closed unless `-i`/`--interactive` is specified. diff --git a/docs/compose-feature-brief.md b/docs/compose-feature-brief.md new file mode 100644 index 000000000..57ebf18d4 --- /dev/null +++ b/docs/compose-feature-brief.md @@ -0,0 +1,146 @@ +# Compose Feature Brief + +> [!IMPORTANT] +> This file contains documentation for the CURRENT BRANCH. To find documentation for official releases, find the target release on the [Release Page](https://github.com/apple/container/releases) and click the tag corresponding to your release version. + +## Overview + +`container compose` adds first-class multi-service workflow support to the `container` CLI for common local-development Compose projects. + +The goal of the feature is not full Docker Compose parity. The goal is to make existing `compose.yaml` style application setups usable on top of the `container` runtime and networking model, with clear validation for unsupported fields instead of silent partial behavior. + +## What We Added + +The CLI now supports: + +* `container compose config` +* `container compose up` +* `container compose down` +* `container compose ps` +* `container compose logs` + +The command discovers these default filenames in order: + +```bash +compose.yaml +compose.yml +docker-compose.yaml +docker-compose.yml +``` + +Supported MVP fields include: + +* Top-level: `name`, `services`, `networks`, `volumes` +* Service: `image`, `build.context`, `build.dockerfile`, `build.args`, `build.target`, `command`, `entrypoint`, `environment`, `env_file`, `ports`, `volumes`, `depends_on`, `depends_on.condition`, `networks`, `working_dir`, `user`, `tty`, `stdin_open`, `profiles`, `healthcheck` + +Unsupported fields fail validation with explicit errors. + +## Implementation Summary + +The feature is implemented directly inside the CLI rather than as a plugin or separate daemon API. + +### Compose parsing and normalization + +The Compose loader reads YAML, interpolates environment variables, validates unsupported keys, and normalizes the project into an internal execution model. + +Important normalization choices: + +* named volumes become project-scoped volume names such as `_` +* named networks become project-scoped network names such as `_` +* services are ordered topologically from `depends_on` +* `depends_on.condition` currently supports `service_started` and `service_healthy` +* service `command` and `entrypoint` strings are tokenized into executable arguments without implicit shell wrapping + +That last point matters because Compose string commands do not behave like a Dockerfile shell-form `CMD`. This implementation had to preserve cases such as: + +* `command: server /data --console-address ":9001"` +* `entrypoint: /bin/sh -c "some setup command"` + +### Runtime translation + +Compose services are translated into existing `container` runtime primitives. + +Each service becomes a single container with Compose-specific labels: + +* `com.apple.container.compose.project` +* `com.apple.container.compose.service` +* `com.apple.container.compose.version` +* `com.apple.container.compose.config-hash` + +The executor then: + +* creates project networks and volumes +* builds images when needed +* creates containers with deterministic names +* starts services in dependency order +* waits for dependency readiness when `depends_on.condition` requires it + +### Healthchecks and startup ordering + +`service_started` waits for the dependency container to reach a running state. + +`service_healthy` runs the dependency healthcheck inside the target container and retries according to the Compose healthcheck definition. + +This support was needed for real-world workflows where a short-lived setup service should only start after a backing service is ready. + +### Service-name networking + +Compose services expect other services to be reachable by service name on the project network. + +To make that work on top of the `container` network model, Compose-attached containers are given network hostnames based on the Compose service name rather than only the generated container name. That allows service-to-service resolution like: + +* `minio-create-bucket -> minio` +* `web -> db` + +## Practical Example + +The feature was validated against a real multi-service project with: + +* PostgreSQL +* MinIO +* a one-shot MinIO bucket bootstrap service + +That validation drove several correctness fixes: + +* healthcheck support +* `depends_on.condition` support +* safe construction of parser-backed CLI flag models inside internal code paths +* proper tokenization of Compose string commands and entrypoints +* network hostnames based on Compose service names + +## Limitations + +This is still an MVP local-development implementation, not full Docker Compose compatibility. + +Known limits include: + +* one container per service +* no scaling or replicas +* unsupported fields still fail validation +* no broad parity for advanced production-oriented Compose features +* limited lifecycle semantics for one-shot services beyond start ordering and readiness + +## Testing and Validation + +The feature has both normalization tests and CLI-level regression tests. + +Coverage includes: + +* file discovery +* interpolation +* unsupported-key validation +* healthcheck parsing +* `depends_on.condition` +* string command and entrypoint tokenization +* `compose up` regression for parser-backed initialization bugs +* service-name DNS resolution across the project network + +In addition to test coverage, the feature was validated manually against a real Compose project to confirm: + +* long-running services stay running +* one-shot setup services run and stop cleanly +* dependent services can resolve each other by Compose service name + +## Brief Summary + +`container compose` brings practical multi-service local development to the `container` CLI by translating a supported subset of Compose into native `container` runtime, networking, and volume operations. The implementation intentionally favors explicit validation, deterministic naming, and runtime correctness over silent partial compatibility. The result is a usable Compose workflow for real application stacks, with enough guardrails to make unsupported behavior obvious. diff --git a/docs/how-to.md b/docs/how-to.md index 12a46cbc0..e5dba26f5 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -7,6 +7,64 @@ How to use the features of `container`. +## Run a multi-service Compose project + +Use `container compose` to run supported local-development Compose files. The command discovers files in this order: + +```bash +compose.yaml +compose.yml +docker-compose.yaml +docker-compose.yml +``` + +For an implementation-oriented summary of how the feature works and what it currently supports, see the [Compose feature brief](./compose-feature-brief.md). + +This example starts a web service and a database, creating the project-scoped network and named volume automatically: + +```yaml +name: sample +services: + db: + image: postgres:16 + volumes: + - data:/var/lib/postgresql/data + web: + image: nginx:latest + depends_on: + - db + ports: + - "8080:80" +volumes: + data: {} +``` + +Validate the project: + +```bash +container compose config +``` + +Build and start services: + +```bash +container compose up --build +``` + +Inspect the resulting project containers: + +```bash +container compose ps +``` + +Stop the project and remove the project volume: + +```bash +container compose down --volumes +``` + +The MVP Compose support validates unsupported keys instead of ignoring them. It now supports service `healthcheck` plus `depends_on.condition: service_started|service_healthy`. It still rejects `deploy`, `secrets`, `configs`, `extends`, `develop`, `container_name`, and `depends_on.condition: service_completed_successfully`. + ## Configure memory and CPUs for your containers Since the containers created by `container` are lightweight virtual machines, consider the needs of your containerized application when you use `container run`. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 GiBytes of memory, use: diff --git a/docs/tutorial.md b/docs/tutorial.md index d6ccf1533..ceb7c56bd 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -5,6 +5,9 @@ > > Example: [release 0.4.1 tag](https://github.com/apple/container/tree/0.4.1) +> [!NOTE] +> For multi-service local-development setups, see `container compose` in the [command reference](./command-reference.md#container-compose) and [how-to guide](./how-to.md#run-a-multi-service-compose-project). + Take a guided tour of `container` by building, running, and publishing a simple web server image. ## Try out the `container` CLI