From 6c8441c176367f9fc9dcb54064ad555cad58fd05 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Thu, 25 Jun 2026 10:30:39 +0600 Subject: [PATCH 1/5] Improve Apple container compose compatibility --- .../Codable Structs/Healthcheck.swift | 60 +++++ .../Codable Structs/Service.swift | 33 ++- .../Codable Structs/ServiceDependency.swift | 45 ++++ .../Codable Structs/ServiceNetwork.swift | 37 +++ .../Commands/ComposeUp.swift | 219 +++++++++++++++--- Sources/Container-Compose/Errors.swift | 21 ++ .../Container-Compose/Helper Functions.swift | 18 +- .../DockerComposeParsingTests.swift | 2 + .../EntrypointCommandTests.swift | 12 + .../HealthcheckConfigurationTests.swift | 19 ++ .../HelperFunctionsTests.swift | 23 ++ .../NetworkConfigurationTests.swift | 26 ++- 12 files changed, 461 insertions(+), 54 deletions(-) create mode 100644 Sources/Container-Compose/Codable Structs/ServiceDependency.swift create mode 100644 Sources/Container-Compose/Codable Structs/ServiceNetwork.swift diff --git a/Sources/Container-Compose/Codable Structs/Healthcheck.swift b/Sources/Container-Compose/Codable Structs/Healthcheck.swift index b7e8fc53..fad17472 100644 --- a/Sources/Container-Compose/Codable Structs/Healthcheck.swift +++ b/Sources/Container-Compose/Codable Structs/Healthcheck.swift @@ -21,6 +21,7 @@ // Created by Morris Richman on 6/17/25. // +import Foundation /// Healthcheck configuration for a service. public struct Healthcheck: Codable, Hashable { @@ -66,4 +67,63 @@ public struct Healthcheck: Codable, Hashable { self.retries = try container.decodeIfPresent(Int.self, forKey: .retries) self.timeout = try container.decodeIfPresent(String.self, forKey: .timeout) } + + public var isDisabled: Bool { + test?.first?.uppercased() == "NONE" + } + + public var execArguments: [String]? { + guard let test, !test.isEmpty, !isDisabled else { + return nil + } + + switch test[0].uppercased() { + case "CMD": + let command = Array(test.dropFirst()) + return command.isEmpty ? nil : command + case "CMD-SHELL": + let command = test.dropFirst().joined(separator: " ") + return command.isEmpty ? nil : ["sh", "-c", command] + default: + return test + } + } + + public static func parseDuration(_ value: String?, default defaultValue: TimeInterval) -> TimeInterval { + guard let value, !value.isEmpty else { + return defaultValue + } + + if let seconds = TimeInterval(value) { + return seconds + } + + let units: [String: TimeInterval] = [ + "ns": 0.000000001, + "us": 0.000001, + "µs": 0.000001, + "ms": 0.001, + "s": 1, + "m": 60, + "h": 3600, + ] + let pattern = #"([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)"# + let regex = try! NSRegularExpression(pattern: pattern) + let range = NSRange(value.startIndex.. = [ @@ -174,7 +181,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("\n--- Processing Volumes ---") for (volumeName, volumeConfig) in volumes { guard let volumeConfig else { continue } - await createVolumeHardLink(name: volumeName, config: volumeConfig) + try await createVolume(name: volumeName, config: volumeConfig) } print("--- Volumes Processed ---\n") } @@ -246,6 +253,22 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return (entrypointFlag, positional) } + static func hostnameRunArgs( + hostname: String?, + serviceName: String, + environmentVariables: [String: String] + ) -> (args: [String], warning: String?) { + guard let hostname else { + return ([], nil) + } + + let resolvedHostname = resolveVariable(hostname, with: environmentVariables) + return ( + [], + "Warning: Service '\(serviceName)' defines hostname '\(resolvedHostname)', but Apple Container does not currently expose a container run hostname flag." + ) + } + private func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } @@ -261,14 +284,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return ip } - /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// Repeatedly checks `container list -a` until the given container is listed as `running` or `stopped`. /// - Parameters: /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). /// - timeout: Max seconds to wait before failing. /// - interval: How often to poll (in seconds). - /// - Returns: `true` if the container reached "running" state within the timeout. - private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } + private func waitUntilServiceStarted(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws -> ServiceStartState { + guard let projectName else { throw ComposeError.invalidProjectName } let containerName = "\(projectName)-\(serviceName)" let deadline = Date().addingTimeInterval(timeout) @@ -278,14 +300,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) let container = try? await client.get(id: containerName) if container?.status == .running { - return + return .running + } + if container?.status == .stopped { + return .completed } } throw NSError( domain: "ContainerWait", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to start." ]) } @@ -323,17 +348,28 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { - guard let projectName else { return } - let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name + private func createVolume(name volumeName: String, config volumeConfig: Volume) async throws { + let actualVolumeName = volumeConfig.name ?? volumeConfig.external?.name ?? volumeName + + if volumeConfig.external?.isExternal == true { + print("Info: Volume '\(volumeName)' is declared as external.") + print("This tool assumes external volume '\(actualVolumeName)' already exists and will not attempt to create it.") + return + } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) + if (try? await ClientVolume.inspect(actualVolumeName)) != nil { + print("Volume '\(actualVolumeName)' already exists") + return + } - print( - "Warning: Volume source '\(actualVolumeName)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." + print("Creating volume: \(volumeName) (Actual name: \(actualVolumeName))") + _ = try await ClientVolume.create( + name: actualVolumeName, + driver: volumeConfig.driver ?? "local", + driverOpts: volumeConfig.driver_opts ?? [:], + labels: volumeConfig.labels ?? [:] ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + print("Volume '\(actualVolumeName)' created") } private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws { @@ -343,7 +379,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Info: Network '\(networkName)' is declared as external.") print("This tool assumes external network '\(externalNetwork.name ?? actualNetworkName)' already exists and will not attempt to create it.") } else { - var networkCreateArgs: [String] = ["network", "create"] + let networkCreateArgs: [String] = ["network", "create"] #warning("Docker Compose Network Options Not Supported") // Add driver and driver options @@ -401,6 +437,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { guard let projectName else { throw ComposeError.invalidProjectName } + try waitForDependencyConditions(serviceName: serviceName, service: service) + var imageToRun: String var runCommandArgs: [String] = [] @@ -477,7 +515,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Add volume mounts if let volumes = service.volumes { for volume in volumes { - let args = try await configVolume(volume) + let args = try await configVolume(volume, from: dockerCompose) runCommandArgs.append(contentsOf: args) } } @@ -537,6 +575,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork runCommandArgs.append("--network") runCommandArgs.append(networkToConnect) + + if let aliases = service.networkConfigurations?[network]?.aliases, !aliases.isEmpty { + print( + "Warning: Service '\(serviceName)' defines network aliases for '\(network)' (\(aliases.joined(separator: ", "))), but Apple Container does not currently expose a container run alias flag." + ) + } } print( "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composePath)." @@ -548,12 +592,16 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Note: Service '\(serviceName)' is not explicitly connected to any networks. It will likely use the default bridge network.") } - // Add hostname - if let hostname = service.hostname { - let resolvedHostname = resolveVariable(hostname, with: environmentVariables) - runCommandArgs.append("--hostname") - runCommandArgs.append(resolvedHostname) + // Apple Container 1.0.0 does not expose a `container run` hostname flag. + let hostnameTranslation = Self.hostnameRunArgs( + hostname: service.hostname, + serviceName: serviceName, + environmentVariables: environmentVariables + ) + if let warning = hostnameTranslation.warning { + print(warning) } + runCommandArgs.append(contentsOf: hostnameTranslation.args) // Add working directory if let workingDir = service.working_dir { @@ -636,26 +684,120 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - self.containerConsoleColors[serviceName] = serviceColor + let selectedColor = serviceColor + self.containerConsoleColors[serviceName] = selectedColor - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(selectedColor)) + } + if detach { print("\nStarting service: \(serviceName)") print("Starting \(serviceName)") print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + let exitCode = try await streamCommand( + "container", + args: ["run"] + runCommandArgs, + onStdout: handleOutput, + onStderr: handleOutput + ) + guard exitCode == 0 else { + throw ComposeError.containerRunFailed(serviceName, exitCode) + } + } else { + Task { [self, selectedColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(selectedColor)) + } + + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let exitCode = try await streamCommand( + "container", + args: ["run"] + runCommandArgs, + onStdout: handleOutput, + onStderr: handleOutput + ) + if exitCode != 0 { + fputs("Error: Service '\(serviceName)' exited with status \(exitCode).\n", stderr) + } + } } - do { - try await waitUntilServiceIsRunning(serviceName) + let startState = try await waitUntilServiceStarted(serviceName) + serviceStartStates[serviceName] = startState + + switch startState { + case .running: try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) + if let healthcheck = service.healthcheck, !healthcheck.isDisabled { + try await waitUntilServiceIsHealthy(serviceName: serviceName, healthcheck: healthcheck) + serviceHealth[serviceName] = true + } + case .completed: + if let healthcheck = service.healthcheck, !healthcheck.isDisabled { + throw ComposeError.healthcheckUnavailable(serviceName) + } + } + } + + private func waitForDependencyConditions(serviceName: String, service: Service) throws { + for dependencyName in service.depends_on ?? [] { + let dependency = service.dependencyConditions?[dependencyName] ?? ServiceDependency() + switch dependency.effectiveCondition { + case ServiceDependency.serviceStarted: + guard serviceStartStates[dependencyName] != nil else { + throw ComposeError.dependencyNotStarted(serviceName, dependencyName) + } + case ServiceDependency.serviceHealthy: + guard serviceHealth[dependencyName] == true else { + throw ComposeError.dependencyNotHealthy(serviceName, dependencyName) + } + case ServiceDependency.serviceCompletedSuccessfully: + guard serviceStartStates[dependencyName] == .completed else { + throw ComposeError.dependencyNotCompleted(serviceName, dependencyName) + } + default: + throw ComposeError.unsupportedDependencyCondition(serviceName, dependencyName, dependency.effectiveCondition) + } + } + } + + private func waitUntilServiceIsHealthy(serviceName: String, healthcheck: Healthcheck) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + guard let execArguments = healthcheck.execArguments else { + return + } + + let containerName = "\(projectName)-\(serviceName)" + let retries = max(healthcheck.retries ?? 3, 1) + let interval = Healthcheck.parseDuration(healthcheck.interval, default: 30) + let startPeriod = Healthcheck.parseDuration(healthcheck.start_period, default: 0) + + if startPeriod > 0 { + try await Task.sleep(nanoseconds: UInt64(startPeriod * 1_000_000_000)) } + + for attempt in 1...retries { + let exitCode = try await streamCommand( + "container", + args: ["exec", containerName] + execArguments, + onStdout: { _ in }, + onStderr: { _ in } + ) + if exitCode == 0 { + return + } + + if attempt < retries { + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + + throw ComposeError.healthcheckFailed(serviceName) } private func pullImage(_ imageName: String, platform: String?) async throws { @@ -751,8 +893,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return imageToRun } - private func configVolume(_ volume: String) async throws -> [String] { - try composeVolumeToRunArgs(volume, cwd: cwd, fileManager: fileManager, environmentVariables: environmentVariables, projectName: projectName) + private func configVolume(_ volume: String, from dockerCompose: DockerCompose) async throws -> [String] { + try composeVolumeToRunArgs( + volume, + cwd: cwd, + fileManager: fileManager, + environmentVariables: environmentVariables, + projectName: projectName, + volumeDefinitions: dockerCompose.volumes + ) } } diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index b408ba60..374dfaca 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -39,6 +39,13 @@ public enum YamlError: Error, LocalizedError { public enum ComposeError: Error, LocalizedError { case imageNotFound(String) case invalidProjectName + case containerRunFailed(String, Int32) + case dependencyNotStarted(String, String) + case dependencyNotHealthy(String, String) + case dependencyNotCompleted(String, String) + case unsupportedDependencyCondition(String, String, String) + case healthcheckUnavailable(String) + case healthcheckFailed(String) public var errorDescription: String? { switch self { @@ -46,6 +53,20 @@ public enum ComposeError: Error, LocalizedError { return "Service \(name) must define either 'image' or 'build'." case .invalidProjectName: return "Could not find project name." + case .containerRunFailed(let service, let exitCode): + return "Service '\(service)' failed to start (container run exited with status \(exitCode))." + case .dependencyNotStarted(let service, let dependency): + return "Service '\(service)' depends on '\(dependency)', but '\(dependency)' has not started." + case .dependencyNotHealthy(let service, let dependency): + return "Service '\(service)' depends on '\(dependency)' with condition 'service_healthy', but '\(dependency)' is not healthy." + case .dependencyNotCompleted(let service, let dependency): + return "Service '\(service)' depends on '\(dependency)' with condition 'service_completed_successfully', but '\(dependency)' has not completed successfully." + case .unsupportedDependencyCondition(let service, let dependency, let condition): + return "Service '\(service)' depends on '\(dependency)' with unsupported condition '\(condition)'." + case .healthcheckUnavailable(let service): + return "Service '\(service)' defines a healthcheck but completed before the healthcheck could run." + case .healthcheckFailed(let service): + return "Service '\(service)' failed its healthcheck." } } } diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index ff8ce48f..6c102a65 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -160,7 +160,8 @@ func composeVolumeToRunArgs( cwd: String, fileManager: FileManager = .default, environmentVariables: [String: String] = [:], - projectName: String? + projectName: String?, + volumeDefinitions: [String: Volume?]? = nil ) throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) var args: [String] = [] @@ -197,20 +198,11 @@ func composeVolumeToRunArgs( } } } else { - guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + let volumeDefinition = volumeDefinitions?[source] ?? nil + let actualVolumeName = volumeDefinition?.name ?? volumeDefinition?.external?.name ?? source args.append("-v") - let modeStr = mode.map { ":\($0)" } ?? "" - args.append("\(volumePath):\(destinationPath)\(modeStr)") + args.append(bindMountArg(source: actualVolumeName)) } return args diff --git a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift index 90c5ba85..00dd7821 100644 --- a/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/DockerComposeParsingTests.swift @@ -227,6 +227,8 @@ struct DockerComposeParsingTests { #expect(compose.services["web"]??.depends_on?.contains("db") == true) #expect(compose.services["web"]??.depends_on?.contains("cache") == true) #expect(compose.services["web"]??.depends_on?.count == 2) + #expect(compose.services["web"]??.dependencyConditions?["db"]?.condition == "service_healthy") + #expect(compose.services["web"]??.dependencyConditions?["cache"]?.condition == "service_started") } @Test("Parse compose with build context") diff --git a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift index 51a9d1f5..9c835322 100644 --- a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift +++ b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift @@ -107,4 +107,16 @@ struct EntrypointCommandTests { #expect(r.entrypointFlag == "/bin/sh") #expect(r.positional == []) } + + @Test("hostname does not emit unsupported container run flag") + func hostnameDoesNotEmitUnsupportedRunFlag() { + let r = ComposeUp.hostnameRunArgs( + hostname: "${HOSTNAME_VALUE}", + serviceName: "web", + environmentVariables: ["HOSTNAME_VALUE": "custom-host"] + ) + + #expect(r.args.isEmpty) + #expect(r.warning == "Warning: Service 'web' defines hostname 'custom-host', but Apple Container does not currently expose a container run hostname flag.") + } } diff --git a/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift b/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift index eb2c6c9b..7f2ab59f 100644 --- a/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift +++ b/Tests/Container-Compose-StaticTests/HealthcheckConfigurationTests.swift @@ -132,6 +132,25 @@ struct HealthcheckConfigurationTests { #expect(healthcheck.test?.first == "CMD-SHELL") } + + @Test("Healthcheck CMD translates to exec arguments") + func healthcheckCmdExecArguments() throws { + let healthcheck = Healthcheck(test: ["CMD", "curl", "-f", "http://localhost"]) + #expect(healthcheck.execArguments == ["curl", "-f", "http://localhost"]) + } + + @Test("Healthcheck CMD-SHELL translates to shell exec arguments") + func healthcheckCmdShellExecArguments() throws { + let healthcheck = Healthcheck(test: ["CMD-SHELL", "curl -f http://localhost || exit 1"]) + #expect(healthcheck.execArguments == ["sh", "-c", "curl -f http://localhost || exit 1"]) + } + + @Test("Healthcheck duration parser supports combined units") + func healthcheckDurationParser() throws { + #expect(Healthcheck.parseDuration("1m30s", default: 0) == 90) + #expect(Healthcheck.parseDuration("250ms", default: 0) == 0.25) + #expect(Healthcheck.parseDuration(nil, default: 7) == 7) + } @Test("Disable healthcheck") func disableHealthcheck() throws { diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index be2ec08f..20bd806e 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -220,4 +220,27 @@ struct ComposeVolumeTests { #expect(result == []) } + @Test("Named volume is forwarded using native container volume syntax") + func testNamedVolumeUsesNativeVolumeSyntax() throws { + let result = try composeVolumeToRunArgs( + "data:/var/lib/postgresql/data", + cwd: "/tmp", + projectName: "test" + ) + #expect(result == ["-v", "data:/var/lib/postgresql/data"]) + } + + @Test("Named volume uses explicit top-level volume name") + func testNamedVolumeUsesExplicitTopLevelName() throws { + let result = try composeVolumeToRunArgs( + "db-data:/var/lib/postgresql/data:ro", + cwd: "/tmp", + projectName: "test", + volumeDefinitions: [ + "db-data": Volume(name: "prod-db-data") + ] + ) + #expect(result == ["-v", "prod-db-data:/var/lib/postgresql/data:ro"]) + } + } diff --git a/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift b/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift index f368f8a7..0f85ece8 100644 --- a/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift +++ b/Tests/Container-Compose-StaticTests/NetworkConfigurationTests.swift @@ -65,6 +65,31 @@ struct NetworkConfigurationTests { #expect(compose.services["app"]??.networks?.contains("frontend") == true) #expect(compose.services["app"]??.networks?.contains("backend") == true) } + + @Test("Parse service network object form with aliases") + func parseServiceNetworkObjectFormWithAliases() throws { + let yaml = """ + version: '3.8' + services: + web: + image: nginx:latest + networks: + frontend: + aliases: + - web.local + backend: + networks: + frontend: + backend: + """ + + let decoder = YAMLDecoder() + let compose = try decoder.decode(DockerCompose.self, from: yaml) + + #expect(compose.services["web"]??.networks == ["backend", "frontend"]) + #expect(compose.services["web"]??.networkConfigurations?["frontend"]?.aliases == ["web.local"]) + #expect(compose.services["web"]??.networkConfigurations?["backend"] != nil) + } @Test("Parse network with driver") func parseNetworkWithDriver() throws { @@ -187,4 +212,3 @@ struct NetworkConfigurationTests { #expect(compose.services["web"] != nil) } } - From c5bea24b2fcc6e231e2c87f6845749fef48058cd Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Thu, 25 Jun 2026 11:32:01 +0600 Subject: [PATCH 2/5] Support Apple container network aliases when available --- .../Commands/ComposeUp.swift | 45 ++++++++++++++++--- .../EntrypointCommandTests.swift | 28 ++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 9714fe3a..58e4a3e6 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -269,6 +269,36 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ) } + static func networkRunArg( + network: String, + aliases: [String], + serviceName: String, + environmentVariables: [String: String], + supportsAliases: Bool = supportsNetworkAliases() + ) -> (arg: String, warning: String?) { + let resolvedAliases = aliases.map { resolveVariable($0, with: environmentVariables) } + guard !resolvedAliases.isEmpty else { + return (network, nil) + } + + if supportsAliases { + let aliasProperties = resolvedAliases.map { "alias=\($0)" }.joined(separator: ",") + return ("\(network),\(aliasProperties)", nil) + } + + return ( + network, + "Warning: Service '\(serviceName)' defines network aliases for '\(network)' (\(resolvedAliases.joined(separator: ", "))), but the linked Apple Container command parser does not expose a container run alias property." + ) + } + + private static func supportsNetworkAliases() -> Bool { + (try? Application.ContainerRun.parse([ + "--network", "container-compose-probe,alias=container-compose-probe", + "alpine:latest", + ])) != nil + } + private func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } @@ -574,12 +604,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Use the explicit network name from top-level definition if available, otherwise resolved name let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork runCommandArgs.append("--network") - runCommandArgs.append(networkToConnect) - - if let aliases = service.networkConfigurations?[network]?.aliases, !aliases.isEmpty { - print( - "Warning: Service '\(serviceName)' defines network aliases for '\(network)' (\(aliases.joined(separator: ", "))), but Apple Container does not currently expose a container run alias flag." - ) + let networkTranslation = Self.networkRunArg( + network: networkToConnect, + aliases: service.networkConfigurations?[network]?.aliases ?? [], + serviceName: serviceName, + environmentVariables: environmentVariables + ) + runCommandArgs.append(networkTranslation.arg) + if let warning = networkTranslation.warning { + print(warning) } } print( diff --git a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift index 9c835322..db5f38cf 100644 --- a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift +++ b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift @@ -119,4 +119,32 @@ struct EntrypointCommandTests { #expect(r.args.isEmpty) #expect(r.warning == "Warning: Service 'web' defines hostname 'custom-host', but Apple Container does not currently expose a container run hostname flag.") } + + @Test("network aliases emit Apple alias properties when supported") + func networkAliasesEmitWhenSupported() { + let r = ComposeUp.networkRunArg( + network: "backend", + aliases: ["${SERVICE_ALIAS}", "database"], + serviceName: "web", + environmentVariables: ["SERVICE_ALIAS": "db"], + supportsAliases: true + ) + + #expect(r.arg == "backend,alias=db,alias=database") + #expect(r.warning == nil) + } + + @Test("network aliases warn when Apple alias properties are unsupported") + func networkAliasesWarnWhenUnsupported() { + let r = ComposeUp.networkRunArg( + network: "backend", + aliases: ["${SERVICE_ALIAS}", "database"], + serviceName: "web", + environmentVariables: ["SERVICE_ALIAS": "db"], + supportsAliases: false + ) + + #expect(r.arg == "backend") + #expect(r.warning == "Warning: Service 'web' defines network aliases for 'backend' (db, database), but the linked Apple Container command parser does not expose a container run alias property.") + } } From 4c9ed0f550f7b35f27d43a8db0e1ecffac171851 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Fri, 26 Jun 2026 00:19:41 +0600 Subject: [PATCH 3/5] Add service name network aliases --- .../Commands/ComposeUp.swift | 9 ++--- .../EntrypointCommandTests.swift | 34 +++++++++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 58e4a3e6..7f40c08d 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -276,10 +276,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { environmentVariables: [String: String], supportsAliases: Bool = supportsNetworkAliases() ) -> (arg: String, warning: String?) { - let resolvedAliases = aliases.map { resolveVariable($0, with: environmentVariables) } - guard !resolvedAliases.isEmpty else { - return (network, nil) - } + let resolvedAliases = ([serviceName] + aliases.map { resolveVariable($0, with: environmentVariables) }) + .reduce(into: [String]()) { result, alias in + guard !alias.isEmpty, !result.contains(alias) else { return } + result.append(alias) + } if supportsAliases { let aliasProperties = resolvedAliases.map { "alias=\($0)" }.joined(separator: ",") diff --git a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift index db5f38cf..bdee81cc 100644 --- a/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift +++ b/Tests/Container-Compose-StaticTests/EntrypointCommandTests.swift @@ -120,7 +120,7 @@ struct EntrypointCommandTests { #expect(r.warning == "Warning: Service 'web' defines hostname 'custom-host', but Apple Container does not currently expose a container run hostname flag.") } - @Test("network aliases emit Apple alias properties when supported") + @Test("network aliases emit service name and Apple alias properties when supported") func networkAliasesEmitWhenSupported() { let r = ComposeUp.networkRunArg( network: "backend", @@ -130,7 +130,35 @@ struct EntrypointCommandTests { supportsAliases: true ) - #expect(r.arg == "backend,alias=db,alias=database") + #expect(r.arg == "backend,alias=web,alias=db,alias=database") + #expect(r.warning == nil) + } + + @Test("network aliases emit service name when no explicit aliases are configured") + func networkAliasesEmitServiceNameByDefault() { + let r = ComposeUp.networkRunArg( + network: "backend", + aliases: [], + serviceName: "web", + environmentVariables: [:], + supportsAliases: true + ) + + #expect(r.arg == "backend,alias=web") + #expect(r.warning == nil) + } + + @Test("network aliases do not duplicate explicit service name alias") + func networkAliasesDoNotDuplicateServiceName() { + let r = ComposeUp.networkRunArg( + network: "backend", + aliases: ["web", "database"], + serviceName: "web", + environmentVariables: [:], + supportsAliases: true + ) + + #expect(r.arg == "backend,alias=web,alias=database") #expect(r.warning == nil) } @@ -145,6 +173,6 @@ struct EntrypointCommandTests { ) #expect(r.arg == "backend") - #expect(r.warning == "Warning: Service 'web' defines network aliases for 'backend' (db, database), but the linked Apple Container command parser does not expose a container run alias property.") + #expect(r.warning == "Warning: Service 'web' defines network aliases for 'backend' (web, db, database), but the linked Apple Container command parser does not expose a container run alias property.") } } From bdc0a641014e5fd51f08dfeb9cca73b7ca3d1954 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Tue, 30 Jun 2026 09:01:26 +0600 Subject: [PATCH 4/5] Address compose compatibility review comments --- Package.resolved | 2 +- Package.swift | 4 ++ .../Codable Structs/Healthcheck.swift | 26 ++++---- .../Commands/ComposeUp.swift | 65 ++++++++++++++++--- .../Container-Compose/Helper Functions.swift | 26 +++++++- .../HelperFunctionsTests.swift | 30 ++++++++- .../ServiceDependencyTests.swift | 27 +++++++- 7 files changed, 154 insertions(+), 26 deletions(-) diff --git a/Package.resolved b/Package.resolved index 75bddb6f..5ca67bbd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "88a8c9d7a638866eedf9a6130a6e13bdb1a21e134677e63d45429b307709c569", + "originHash" : "79acd8672b13f4d188e7a4c5d86093adfa71839a04733296d715993c9f62ce99", "pins" : [ { "identity" : "async-http-client", diff --git a/Package.swift b/Package.swift index 04515f68..9f540444 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,10 @@ let package = Package( name: "ContainerCommands", package: "container" ), + .product( + name: "ContainerXPC", + package: "container" + ), .product( name: "ArgumentParser", package: "swift-argument-parser" diff --git a/Sources/Container-Compose/Codable Structs/Healthcheck.swift b/Sources/Container-Compose/Codable Structs/Healthcheck.swift index fad17472..4818892d 100644 --- a/Sources/Container-Compose/Codable Structs/Healthcheck.swift +++ b/Sources/Container-Compose/Codable Structs/Healthcheck.swift @@ -25,6 +25,17 @@ import Foundation /// Healthcheck configuration for a service. public struct Healthcheck: Codable, Hashable { + private static let durationUnits: [String: TimeInterval] = [ + "ns": 0.000000001, + "us": 0.000001, + "µs": 0.000001, + "ms": 0.001, + "s": 1, + "m": 60, + "h": 3600, + ] + private static let durationRegex = try! NSRegularExpression(pattern: #"([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)"#) + /// Command to run to check health public let test: [String]? /// Grace period for the container to start @@ -98,19 +109,8 @@ public struct Healthcheck: Codable, Hashable { return seconds } - let units: [String: TimeInterval] = [ - "ns": 0.000000001, - "us": 0.000001, - "µs": 0.000001, - "ms": 0.001, - "s": 1, - "m": 60, - "h": 3600, - ] - let pattern = #"([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)"# - let regex = try! NSRegularExpression(pattern: pattern) let range = NSRange(value.startIndex.. (arg: String, warning: String?) { let resolvedAliases = ([serviceName] + aliases.map { resolveVariable($0, with: environmentVariables) }) .reduce(into: [String]()) { result, alias in @@ -300,6 +299,40 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ])) != nil } + static func servicesSelectedForUp( + _ services: [(serviceName: String, service: Service)], + requestedServices: [String] + ) -> [(serviceName: String, service: Service)] { + guard !requestedServices.isEmpty else { + return services + } + + let servicesByName = Dictionary(uniqueKeysWithValues: services.map { ($0.serviceName, $0.service) }) + var selected = Set() + + func include(_ serviceName: String) { + guard let service = servicesByName[serviceName], selected.insert(serviceName).inserted else { + return + } + + for dependency in service.depends_on ?? [] { + include(dependency) + } + } + + for serviceName in requestedServices { + include(serviceName) + } + + return services.filter { selected.contains($0.serviceName) } + } + + static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws { + guard exitCode == 0 else { + throw ComposeError.containerRunFailed(serviceName, exitCode) + } + } + private func getIPForRunningService(_ serviceName: String) async throws -> String? { guard let projectName else { return nil } @@ -334,6 +367,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return .running } if container?.status == .stopped { + let exitCode = try await Self.waitForInitExitCode(containerName: containerName) + try Self.validateStoppedServiceExitCode(exitCode, serviceName: serviceName) return .completed } } @@ -345,6 +380,16 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ]) } + private static func waitForInitExitCode(containerName: String) async throws -> Int32 { + let request = XPCMessage(route: .containerWait) + request.set(key: .id, value: containerName) + request.set(key: .processIdentifier, value: containerName) + + let client = XPCClient(service: "com.apple.container.apiserver") + let response = try await client.send(request, responseTimeout: .seconds(10)) + return Int32(response.int64(key: .exitCode)) + } + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" } @@ -380,7 +425,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } private func createVolume(name volumeName: String, config volumeConfig: Volume) async throws { - let actualVolumeName = volumeConfig.name ?? volumeConfig.external?.name ?? volumeName + let actualVolumeName = composeNamedVolumeName( + source: volumeName, + projectName: projectName, + volumeDefinition: volumeConfig + ) if volumeConfig.external?.isExternal == true { print("Info: Volume '\(volumeName)' is declared as external.") diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 6c102a65..a32a20ed 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -199,7 +199,11 @@ func composeVolumeToRunArgs( } } else { let volumeDefinition = volumeDefinitions?[source] ?? nil - let actualVolumeName = volumeDefinition?.name ?? volumeDefinition?.external?.name ?? source + let actualVolumeName = composeNamedVolumeName( + source: source, + projectName: projectName, + volumeDefinition: volumeDefinition + ) args.append("-v") args.append(bindMountArg(source: actualVolumeName)) @@ -208,6 +212,26 @@ func composeVolumeToRunArgs( return args } +func composeNamedVolumeName( + source: String, + projectName: String?, + volumeDefinition: Volume? +) -> String { + if let explicitName = volumeDefinition?.name { + return explicitName + } + + if volumeDefinition?.external?.isExternal == true { + return volumeDefinition?.external?.name ?? source + } + + guard let projectName, !projectName.isEmpty else { + return source + } + + return "\(projectName)_\(source)" +} + extension String: @retroactive Error {} /// A structure representing the result of a command-line process execution. diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index 20bd806e..bb16c7bd 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -220,14 +220,14 @@ struct ComposeVolumeTests { #expect(result == []) } - @Test("Named volume is forwarded using native container volume syntax") + @Test("Named volume is forwarded using project-scoped native container volume syntax") func testNamedVolumeUsesNativeVolumeSyntax() throws { let result = try composeVolumeToRunArgs( "data:/var/lib/postgresql/data", cwd: "/tmp", projectName: "test" ) - #expect(result == ["-v", "data:/var/lib/postgresql/data"]) + #expect(result == ["-v", "test_data:/var/lib/postgresql/data"]) } @Test("Named volume uses explicit top-level volume name") @@ -243,4 +243,30 @@ struct ComposeVolumeTests { #expect(result == ["-v", "prod-db-data:/var/lib/postgresql/data:ro"]) } + @Test("External named volume without explicit name keeps source name") + func testNamedVolumeKeepsExternalSourceName() throws { + let result = try composeVolumeToRunArgs( + "db-data:/var/lib/postgresql/data", + cwd: "/tmp", + projectName: "test", + volumeDefinitions: [ + "db-data": Volume(external: ExternalVolume(isExternal: true, name: nil)) + ] + ) + #expect(result == ["-v", "db-data:/var/lib/postgresql/data"]) + } + + @Test("External named volume uses explicit external name") + func testNamedVolumeUsesExplicitExternalName() throws { + let result = try composeVolumeToRunArgs( + "db-data:/var/lib/postgresql/data", + cwd: "/tmp", + projectName: "test", + volumeDefinitions: [ + "db-data": Volume(external: ExternalVolume(isExternal: true, name: "shared-db-data")) + ] + ) + #expect(result == ["-v", "shared-db-data:/var/lib/postgresql/data"]) + } + } diff --git a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift index 79881616..9aabac86 100644 --- a/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift +++ b/Tests/Container-Compose-StaticTests/ServiceDependencyTests.swift @@ -67,6 +67,32 @@ struct ServiceDependencyTests { #expect(sorted[1].serviceName == "app") #expect(sorted[2].serviceName == "web") } + + @Test("Selecting service keeps transitive dependency closure") + func selectingServiceKeepsTransitiveDependencyClosure() throws { + let worker = Service(image: "worker", depends_on: ["api"]) + let api = Service(image: "api", depends_on: ["db"]) + let db = Service(image: "postgres", depends_on: nil) + let cache = Service(image: "redis", depends_on: nil) + + let services: [(String, Service)] = [ + ("worker", worker), + ("api", api), + ("db", db), + ("cache", cache), + ] + let sorted = try Service.topoSortConfiguredServices(services) + let selected = ComposeUp.servicesSelectedForUp(sorted, requestedServices: ["worker"]) + + #expect(selected.map(\.serviceName) == ["db", "api", "worker"]) + } + + @Test("Stopped service non-zero exit code fails startup") + func stoppedServiceNonZeroExitCodeFailsStartup() throws { + #expect(throws: ComposeError.self) { + try ComposeUp.validateStoppedServiceExitCode(17, serviceName: "db") + } + } @Test("No dependencies - services should maintain order") func noDependencies() throws { @@ -132,4 +158,3 @@ struct ServiceDependencyTests { #expect(sorted.count == 1) } } - From 772494e4e2288b6150b63fcd9f43d92b61555bf4 Mon Sep 17 00:00:00 2001 From: Tanzim Hossain Romel Date: Tue, 30 Jun 2026 09:10:43 +0600 Subject: [PATCH 5/5] Add named volume destination regression coverage --- .../HelperFunctionsTests.swift | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index bb16c7bd..3939ec31 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -230,6 +230,26 @@ struct ComposeVolumeTests { #expect(result == ["-v", "test_data:/var/lib/postgresql/data"]) } + @Test("Named volume mounts at its declared single-segment destination") + func testNamedVolumeMountsAtDeclaredSingleSegmentDestination() throws { + let result = try composeVolumeToRunArgs( + "redisdata:/data", + cwd: "/tmp", + projectName: "proj" + ) + #expect(result == ["-v", "proj_redisdata:/data"]) + } + + @Test("Named volume preserves nested destination and mode") + func testNamedVolumePreservesNestedDestinationAndMode() throws { + let result = try composeVolumeToRunArgs( + "pgdata:/var/lib/postgresql/data:ro", + cwd: "/tmp", + projectName: "proj" + ) + #expect(result == ["-v", "proj_pgdata:/var/lib/postgresql/data:ro"]) + } + @Test("Named volume uses explicit top-level volume name") func testNamedVolumeUsesExplicitTopLevelName() throws { let result = try composeVolumeToRunArgs(