From f24191dcb257e2a5c9817fb1f7f2513a370a60b0 Mon Sep 17 00:00:00 2001 From: ravshansbox Date: Fri, 6 Mar 2026 04:06:16 +0300 Subject: [PATCH 1/3] fix: handle port mappings with explicit IP bindings correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation blindly prepended '0.0.0.0:' to every port specification, causing invalid formats like '0.0.0.0:127.0.0.1:5432:5432' when the compose file already included an IP binding. Introduced composePortToRunArg helper function that: - Handles simple port (e.g., '3000') → '0.0.0.0:3000:3000' - Handles host:container pairs (e.g., '8080:3000') → '0.0.0.0:8080:3000' - Preserves explicit IP bindings (e.g., '127.0.0.1:5432:5432') as-is - Supports IPv6 bracket notation (e.g., '[::1]:3000:3000') - Preserves protocol suffixes (/tcp, /udp) Added comprehensive unit tests covering all port format variations. Fixes the 'invalid publish IPv4 address' error when using explicit IP bindings in docker-compose.yml. --- .../Commands/ComposeUp.swift | 1507 +++++++++-------- .../Container-Compose/Helper Functions.swift | 178 +- .../HelperFunctionsTests.swift | 77 +- 3 files changed, 961 insertions(+), 801 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d274ff9..57d4d60 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -22,766 +22,823 @@ // import ArgumentParser -import ContainerCommands //import ContainerClient import ContainerAPIClient +import ContainerCommands import ContainerizationExtras import Foundation @preconcurrency import Rainbow import Yams public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: "Detaches from container logs. Note: If you do NOT detach, killing this process will NOT kill the container. To kill the container, run container-compose down") - var detach: Bool = false - - @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") - var composeFilename: String = "compose.yml" - private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @OptionGroup - var process: Flags.Process - - @OptionGroup - var logging: Flags.Logging - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, - ] - - public mutating func run() async throws { - // Check for supported filenames and extensions - let filenames = [ - "compose.yml", - "compose.yaml", - "docker-compose.yml", - "docker-compose.yaml", - ] - for filename in filenames { - if fileManager.fileExists(atPath: "\(cwd)/\(filename)") { - composeFilename = filename - break - } - } - - // Read compose.yml content - guard let yamlData = fileManager.contents(atPath: composePath) else { - let path = URL(fileURLWithPath: composePath) - .deletingLastPathComponent() - .path - throw YamlError.composeFileNotFound(path) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) - } else { - projectName = deriveProjectName(cwd: cwd) - print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in - guard let service else { return nil } - return (serviceName, service) - }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - guard let volumeConfig else { continue } - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detach { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - 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 } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } - - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." - ]) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: + "Detaches from container logs. Note: If you do NOT detach, killing this process will NOT kill the container. To kill the container, run container-compose down" + ) + var detach: Bool = false + + @Option( + name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") + var composeFilename: String = "compose.yml" + private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var logging: Flags.Logging + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, + .lightGreen, .green, + ] + + public mutating func run() async throws { + // Check for supported filenames and extensions + let filenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + for filename in filenames { + if fileManager.fileExists(atPath: "\(cwd)/\(filename)") { + composeFilename = filename + break + } + } + + // Read compose.yml content + guard let yamlData = fileManager.contents(atPath: composePath) else { + let path = URL(fileURLWithPath: composePath) + .deletingLastPathComponent() + .path + throw YamlError.composeFileNotFound(path) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print( + "Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema." + ) + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) + } else { + projectName = deriveProjectName(cwd: cwd) + print( + "Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")" + ) + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ + serviceName, service in + guard let service else { return nil } + return (serviceName, service) + }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) + || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + guard let volumeConfig else { continue } + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detach { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - 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 } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return + } + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } + + do { + try await container.stop() + } catch { + print("Error Stopping Container: \(error)") + } + if remove { do { - try await container.stop() + try await container.delete() } catch { - print("Error Stopping Container: \(error)") - } - if remove { - do { - try await container.delete() - } catch { - print("Error Removing Container: \(error)") - } - } - } - } - - // MARK: Compose Top Level Functions - - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } - } - - 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 - - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - - 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." - ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - } - - private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws { - let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig?.external, externalNetwork.isExternal { - 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"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig?.driver, !driver.isEmpty { - // networkCreateArgs.append("--driver") - // networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig?.driver_opts, !driverOpts.isEmpty { - // for (optKey, optValue) in driverOpts { - // networkCreateArgs.append("--opt") - // networkCreateArgs.append("\(optKey)=\(optValue)") - // } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig?.attachable == true { - // networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig?.enable_ipv6 == true { - // networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig?.isInternal == true { - // networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig?.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") - // for (labelKey, labelValue) in labels { - // networkCreateArgs.append("--label") - // networkCreateArgs.append("\(labelKey)=\(labelValue)") - // } + print("Error Removing Container: \(error)") } + } + } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + 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 + + let volumeUrl = URL.homeDirectory.appending( + path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + 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." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws + { + let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig?.external, externalNetwork.isExternal { + 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"] + + #warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig?.driver, !driver.isEmpty { + // networkCreateArgs.append("--driver") + // networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig?.driver_opts, !driverOpts.isEmpty { + // for (optKey, optValue) in driverOpts { + // networkCreateArgs.append("--opt") + // networkCreateArgs.append("\(optKey)=\(optValue)") + // } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig?.attachable == true { + // networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig?.enable_ipv6 == true { + // networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig?.isInternal == true { + // networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig?.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") + // for (labelKey, labelValue) in labels { + // networkCreateArgs.append("--label") + // networkCreateArgs.append("\(labelKey)=\(labelValue)") + // } + } - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - let commands = [actualNetworkName] - - let networkCreate = try Application.NetworkCreate.parse(commands + logging.passThroughCommands()) - - try await networkCreate.run() - print("Network '\(networkName)' created") - } - } - - // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - var runCommandArgs: [String] = [] - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.platform) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Set Run Platform - if let platform = service.platform { - runCommandArgs.append(contentsOf: ["--platform", "\(platform)"]) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - // Add detach flag if specified on the CLI - if detach { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print( + "Executing container network create: container \(networkCreateArgs.joined(separator: " "))" + ) + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + let commands = [actualNetworkName] + + let networkCreate = try Application.NetworkCreate.parse( + commands + logging.passThroughCommands()) + + try await networkCreate.run() + print("Network '\(networkName)' created") + } + } + + // MARK: Compose Service Level Functions + private mutating func configService( + _ service: Service, serviceName: String, from dockerCompose: DockerCompose + ) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + var runCommandArgs: [String] = [] + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.platform) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Set Run Platform + if let platform = service.platform { + runCommandArgs.append(contentsOf: ["--platform", "\(platform)"]) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print( + "Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully." + ) + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) + print("The service will be run as a single container based on other configurations.") + } + + // Add detach flag if specified on the CLI + if detach { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } - } - } - - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old - } - return new - } // Service env overrides .env files - } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") - } - - if let ports = service.ports { - for port in ports { - let resolvedPort = resolveVariable(port, with: environmentVariables) - runCommandArgs.append("-p") - runCommandArgs.append("0.0.0.0:\(resolvedPort)") - } + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } } + } - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // 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 serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + if let ports = service.ports { + for port in ports { + let resolvedPort = resolveVariable(port, with: environmentVariables) + runCommandArgs.append("-p") + runCommandArgs.append(composePortToRunArg(resolvedPort)) + } + } + + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // 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) + } + print( + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composeFilename)." + ) + print( + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." + ) + } else { + 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) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Add resource limits + if let cpus = service.deploy?.resources?.limits?.cpus { + runCommandArgs.append(contentsOf: ["--cpus", cpus]) + } + if let memory = service.deploy?.resources?.limits?.memory { + runCommandArgs.append(contentsOf: ["--memory", memory]) + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print( + "This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'." + ) + for serviceConfig in serviceConfigs { print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composeFilename)." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - 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) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } - - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") - } - - // Add resource limits - if let cpus = service.deploy?.resources?.limits?.cpus { - runCommandArgs.append(contentsOf: ["--cpus", cpus]) - } - if let memory = service.deploy?.resources?.limits?.memory { - runCommandArgs.append(contentsOf: ["--memory", memory]) - } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" ) - print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") - for serviceConfig in serviceConfigs { - print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" - ) - } - } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { + } + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print( + "This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'." + ) + for serviceSecret in serviceSecrets { print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" ) - print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") - for serviceSecret in serviceSecrets { - print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" - ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) + != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) + { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } - self.containerConsoleColors[serviceName] = serviceColor + self.containerConsoleColors[serviceName] = serviceColor - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) + } - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) - } - } - - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { - return - } - - print("Pulling Image \(imageName)...") - - var commands = [ - imageName - ] - - if let platform { - commands.append(contentsOf: ["--platform", platform]) - } - - let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands()) - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { - return imageToRun - } - - // Build command arguments - var commands = ["\(self.cwd)/\(buildConfig.context)"] - - // Add build arguments - for (key, value) in buildConfig.args ?? [:] { - commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"]) - } - - // Add Dockerfile path - commands.append(contentsOf: ["--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")"]) - - // Add caching options - if noCache { - commands.append("--no-cache") - } - - // Add OS/Arch - let split = service.platform?.split(separator: "/") - let os = String(split?.first ?? "linux") - let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") - commands.append(contentsOf: ["--os", os]) - commands.append(contentsOf: ["--arch", arch]) - - // Add image name - commands.append(contentsOf: ["--tag", imageToRun]) - - // Add CPU & Memory - let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" - commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) - commands.append(contentsOf: ["--memory", memoryLimit]) - - let buildCommand = try Application.BuildCommand.parse(commands) - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand( + "container", args: ["run"] + runCommandArgs, onStdout: handleOutput, + onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard + !imageList.contains(where: { + $0.description.reference.components(separatedBy: "/").last == imageName + }) + else { + return + } + + print("Pulling Image \(imageName)...") + + var commands = [ + imageName + ] + + if let platform { + commands.append(contentsOf: ["--platform", platform]) + } + + let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands()) + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) + async throws -> String + { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, + imageList.contains(where: { + $0.description.reference.components(separatedBy: "/").last == imageToRun + }) + { + return imageToRun + } + + // Build command arguments + var commands = ["\(self.cwd)/\(buildConfig.context)"] + + // Add build arguments + for (key, value) in buildConfig.args ?? [:] { + commands.append(contentsOf: [ + "--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))", + ]) + } + + // Add Dockerfile path + commands.append(contentsOf: [ + "--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")", + ]) + + // Add caching options + if noCache { + commands.append("--no-cache") + } + + // Add OS/Arch + let split = service.platform?.split(separator: "/") + let os = String(split?.first ?? "linux") + let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") + commands.append(contentsOf: ["--os", os]) + commands.append(contentsOf: ["--arch", arch]) + + // Add image name + commands.append(contentsOf: ["--tag", imageToRun]) + + // Add CPU & Memory + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" + commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) + commands.append(contentsOf: ["--memory", memoryLimit]) + + let buildCommand = try Application.BuildCommand.parse(commands) + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print( + "Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping." + ) + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = + (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") - } + // Host path exists but is a file + print( + "Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume." + ) } - } 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) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument - } - - return runCommandArgs - } + } else { + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory( + atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print( + "Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume." + ) + } + } + } 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) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } } // MARK: CommandLine Functions extension ComposeUp { - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. - /// - /// - Parameters: - /// - command: The name of the command to run (e.g., `"container"`). - /// - args: Command-line arguments to pass to the command. - /// - onStdout: Closure called with streamed stdout data. - /// - onStderr: Closure called with streamed stderr data. - /// - Returns: The process's exit code. - /// - Throws: If the process fails to launch. - @discardableResult - func streamCommand( - _ command: String, - args: [String] = [], - onStdout: @escaping (@Sendable (String) -> Void), - onStderr: @escaping (@Sendable (String) -> Void) - ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStdout(string) - } + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) } + } - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStderr(string) - } + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) } + } - process.terminationHandler = { proc in - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - continuation.resume(returning: proc.terminationStatus) - } + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - } + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } } diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 9a21a2b..5e1d829 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -21,37 +21,37 @@ // Created by Morris Richman on 6/17/25. // +import ContainerCommands import Foundation -import Yams import Rainbow -import ContainerCommands +import Yams /// Loads environment variables from a .env file. /// - Parameter path: The full path to the .env file. /// - Returns: A dictionary of key-value pairs representing environment variables. public func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. [String: String] { /// - envVars: A dictionary of environment variables to use for resolution. /// - Returns: The string with all recognized environment variables resolved. public func resolveVariable(_ value: String, with envVars: [String: String]) -> String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression(pattern: #"\$\{([A-Za-z0-9_]+)(:?-(.*?))?(:\?(.*?))?\}"#, options: []) - - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch(in: resolvedValue, options: [], range: NSRange(resolvedValue.startIndex.. /// - Parameter cwd: The current working directory path. /// - Returns: A sanitized project name suitable for container naming. public func deriveProjectName(cwd: String) -> String { - // We need to replace '.' with _ because it is not supported in the container name - let projectName = URL(fileURLWithPath: cwd).lastPathComponent.replacingOccurrences(of: ".", with: "_") - return projectName + // We need to replace '.' with _ because it is not supported in the container name + let projectName = URL(fileURLWithPath: cwd).lastPathComponent.replacingOccurrences( + of: ".", with: "_") + return projectName +} + +/// Converts Docker Compose port specification into a container run -p format. +/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol. +/// - Parameter portSpec: The port specification string from docker-compose.yml. +/// - Returns: A properly formatted port binding for `container run -p`. +public func composePortToRunArg(_ portSpec: String) -> String { + // Check for protocol suffix (e.g., "/tcp" or "/udp") + var protocolSuffix = "" + var portBody = portSpec + if let slashRange = portSpec.range(of: "/", options: [.backwards]) { + let afterSlash = portSpec[slashRange.lowerBound...] + let protocolPart = String(afterSlash) + if protocolPart == "/tcp" || protocolPart == "/udp" { + protocolSuffix = protocolPart + portBody = String(portSpec[.. Date: Fri, 6 Mar 2026 04:45:31 +0300 Subject: [PATCH 2/3] chore: reduce diff noise for port binding fix Reapply the explicit IP port binding changes without indentation-only churn so the PR stays focused on behavior and easier to review. --- .../Commands/ComposeUp.swift | 1507 ++++++++--------- .../Container-Compose/Helper Functions.swift | 206 ++- .../HelperFunctionsTests.swift | 103 +- 3 files changed, 873 insertions(+), 943 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 57d4d60..317381b 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -22,823 +22,766 @@ // import ArgumentParser +import ContainerCommands //import ContainerClient import ContainerAPIClient -import ContainerCommands import ContainerizationExtras import Foundation @preconcurrency import Rainbow import Yams public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { - public init() {} - - public static let configuration: CommandConfiguration = .init( - commandName: "up", - abstract: "Start containers with compose" - ) - - @Argument(help: "Specify the services to start") - var services: [String] = [] - - @Flag( - name: [.customShort("d"), .customLong("detach")], - help: - "Detaches from container logs. Note: If you do NOT detach, killing this process will NOT kill the container. To kill the container, run container-compose down" - ) - var detach: Bool = false - - @Option( - name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") - var composeFilename: String = "compose.yml" - private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml - - @Flag(name: [.customShort("b"), .customLong("build")]) - var rebuild: Bool = false - - @Flag(name: .long, help: "Do not use cache") - var noCache: Bool = false - - @OptionGroup - var process: Flags.Process - - @OptionGroup - var logging: Flags.Logging - - private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } - var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file - - private var fileManager: FileManager { FileManager.default } - private var projectName: String? - private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var containerConsoleColors: [String: NamedColor] = [:] - - private static let availableContainerConsoleColors: Set = [ - .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, - .lightGreen, .green, - ] - - public mutating func run() async throws { - // Check for supported filenames and extensions - let filenames = [ - "compose.yml", - "compose.yaml", - "docker-compose.yml", - "docker-compose.yaml", - ] - for filename in filenames { - if fileManager.fileExists(atPath: "\(cwd)/\(filename)") { - composeFilename = filename - break - } - } - - // Read compose.yml content - guard let yamlData = fileManager.contents(atPath: composePath) else { - let path = URL(fileURLWithPath: composePath) - .deletingLastPathComponent() - .path - throw YamlError.composeFileNotFound(path) - } - - // Decode the YAML file into the DockerCompose struct - let dockerComposeString = String(data: yamlData, encoding: .utf8)! - let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) - - // Load environment variables from .env file - environmentVariables = loadEnvFile(path: envFilePath) - - // Handle 'version' field - if let version = dockerCompose.version { - print("Info: Docker Compose file version parsed as: \(version)") - print( - "Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema." - ) - } - - // Determine project name for container naming - if let name = dockerCompose.name { - projectName = name - print("Info: Docker Compose project name parsed as: \(name)") - print( - "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." - ) - } else { - projectName = deriveProjectName(cwd: cwd) - print( - "Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")" - ) - } - - // Get Services to use - var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ - serviceName, service in - guard let service else { return nil } - return (serviceName, service) - }) - services = try Service.topoSortConfiguredServices(services) - - // Filter for specified services - if !self.services.isEmpty { - services = services.filter({ serviceName, service in - self.services.contains(where: { $0 == serviceName }) - || self.services.contains(where: { service.dependedBy.contains($0) }) - }) - } - - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) - - // Process top-level networks - // This creates named networks defined in the docker-compose.yml - if let networks = dockerCompose.networks { - print("\n--- Processing Networks ---") - for (networkName, networkConfig) in networks { - try await setupNetwork(name: networkName, config: networkConfig) - } - print("--- Networks Processed ---\n") - } - - // Process top-level volumes - // This creates named volumes defined in the docker-compose.yml - if let volumes = dockerCompose.volumes { - print("\n--- Processing Volumes ---") - for (volumeName, volumeConfig) in volumes { - guard let volumeConfig else { continue } - await createVolumeHardLink(name: volumeName, config: volumeConfig) - } - print("--- Volumes Processed ---\n") - } - - // Process each service defined in the docker-compose.yml - print("\n--- Processing Services ---") - - print(services.map(\.serviceName)) - for (serviceName, service) in services { - try await configService(service, serviceName: serviceName, from: dockerCompose) - } - - if !detach { - await waitForever() - } - } - - func waitForever() async -> Never { - for await _ in AsyncStream(unfolding: {}) { - // This will never run - } - fatalError("unreachable") - } - - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } - - let containerName = "\(projectName)-\(serviceName)" - - let container = try await ClientContainer.get(id: containerName) - let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first - - return ip - } - - /// Repeatedly checks `container list -a` until the given container is listed as `running`. - /// - 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 } - let containerName = "\(projectName)-\(serviceName)" - - let deadline = Date().addingTimeInterval(timeout) - - while Date() < deadline { - let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return - } + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "up", + abstract: "Start containers with compose" + ) + + @Argument(help: "Specify the services to start") + var services: [String] = [] + + @Flag( + name: [.customShort("d"), .customLong("detach")], + help: "Detaches from container logs. Note: If you do NOT detach, killing this process will NOT kill the container. To kill the container, run container-compose down") + var detach: Bool = false + + @Option(name: [.customShort("f"), .customLong("file")], help: "The path to your Docker Compose file") + var composeFilename: String = "compose.yml" + private var composePath: String { "\(cwd)/\(composeFilename)" } // Path to compose.yml + + @Flag(name: [.customShort("b"), .customLong("build")]) + var rebuild: Bool = false + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @OptionGroup + var process: Flags.Process + + @OptionGroup + var logging: Flags.Logging + + private var cwd: String { process.cwd ?? FileManager.default.currentDirectoryPath } + var envFilePath: String { "\(cwd)/\(process.envFile.first ?? ".env")" } // Path to optional .env file + + private var fileManager: FileManager { FileManager.default } + private var projectName: String? + private var environmentVariables: [String: String] = [:] + private var containerIps: [String: String] = [:] + private var containerConsoleColors: [String: NamedColor] = [:] + + private static let availableContainerConsoleColors: Set = [ + .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, + ] + + public mutating func run() async throws { + // Check for supported filenames and extensions + let filenames = [ + "compose.yml", + "compose.yaml", + "docker-compose.yml", + "docker-compose.yaml", + ] + for filename in filenames { + if fileManager.fileExists(atPath: "\(cwd)/\(filename)") { + composeFilename = filename + break + } + } + + // Read compose.yml content + guard let yamlData = fileManager.contents(atPath: composePath) else { + let path = URL(fileURLWithPath: composePath) + .deletingLastPathComponent() + .path + throw YamlError.composeFileNotFound(path) + } + + // Decode the YAML file into the DockerCompose struct + let dockerComposeString = String(data: yamlData, encoding: .utf8)! + let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString) + + // Load environment variables from .env file + environmentVariables = loadEnvFile(path: envFilePath) + + // Handle 'version' field + if let version = dockerCompose.version { + print("Info: Docker Compose file version parsed as: \(version)") + print("Note: The 'version' field influences how a Docker Compose CLI interprets the file, but this custom 'container-compose' tool directly interprets the schema.") + } + + // Determine project name for container naming + if let name = dockerCompose.name { + projectName = name + print("Info: Docker Compose project name parsed as: \(name)") + print( + "Note: The 'name' field currently only affects container naming (e.g., '\(name)-serviceName'). Full project-level isolation for other resources (networks, implicit volumes) is not implemented by this tool." + ) + } else { + projectName = deriveProjectName(cwd: cwd) + print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") + } + + // Get Services to use + var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in + guard let service else { return nil } + return (serviceName, service) + }) + services = try Service.topoSortConfiguredServices(services) + + // Filter for specified services + if !self.services.isEmpty { + services = services.filter({ serviceName, service in + self.services.contains(where: { $0 == serviceName }) || self.services.contains(where: { service.dependedBy.contains($0) }) + }) + } + + // Stop Services + try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + + // Process top-level networks + // This creates named networks defined in the docker-compose.yml + if let networks = dockerCompose.networks { + print("\n--- Processing Networks ---") + for (networkName, networkConfig) in networks { + try await setupNetwork(name: networkName, config: networkConfig) + } + print("--- Networks Processed ---\n") + } + + // Process top-level volumes + // This creates named volumes defined in the docker-compose.yml + if let volumes = dockerCompose.volumes { + print("\n--- Processing Volumes ---") + for (volumeName, volumeConfig) in volumes { + guard let volumeConfig else { continue } + await createVolumeHardLink(name: volumeName, config: volumeConfig) + } + print("--- Volumes Processed ---\n") + } + + // Process each service defined in the docker-compose.yml + print("\n--- Processing Services ---") + + print(services.map(\.serviceName)) + for (serviceName, service) in services { + try await configService(service, serviceName: serviceName, from: dockerCompose) + } + + if !detach { + await waitForever() + } + } + + func waitForever() async -> Never { + for await _ in AsyncStream(unfolding: {}) { + // This will never run + } + fatalError("unreachable") + } + + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + guard let projectName else { return nil } + + let containerName = "\(projectName)-\(serviceName)" + + let container = try await ClientContainer.get(id: containerName) + let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first + + return ip + } + + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - 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 } + let containerName = "\(projectName)-\(serviceName)" + + let deadline = Date().addingTimeInterval(timeout) + + while Date() < deadline { + let container = try? await ClientContainer.get(id: containerName) + if container?.status == .running { + return + } + + try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + + throw NSError( + domain: "ContainerWait", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + ]) + } + + private func stopOldStuff(_ services: [String], remove: Bool) async throws { + guard let projectName else { return } + let containers = services.map { "\(projectName)-\($0)" } + + for container in containers { + print("Stopping container: \(container)") + guard let container = try? await ClientContainer.get(id: container) else { continue } - try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) - } - - throw NSError( - domain: "ContainerWait", code: 1, - userInfo: [ - NSLocalizedDescriptionKey: - "Timed out waiting for container '\(containerName)' to be running." - ]) - } - - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } - - do { - try await container.stop() - } catch { - print("Error Stopping Container: \(error)") - } - if remove { do { - try await container.delete() + try await container.stop() } catch { - print("Error Removing Container: \(error)") + print("Error Stopping Container: \(error)") + } + if remove { + do { + try await container.delete() + } catch { + print("Error Removing Container: \(error)") + } + } + } + } + + // MARK: Compose Top Level Functions + + private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { + let ip = try await getIPForRunningService(serviceName) + self.containerIps[serviceName] = ip + for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { + self.environmentVariables[key] = ip ?? value + } + } + + 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 + + let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") + let volumePath = volumeUrl.path(percentEncoded: false) + + 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." + ) + try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + } + + private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws { + let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name + + if let externalNetwork = networkConfig?.external, externalNetwork.isExternal { + 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"] + + #warning("Docker Compose Network Options Not Supported") + // Add driver and driver options + if let driver = networkConfig?.driver, !driver.isEmpty { + // networkCreateArgs.append("--driver") + // networkCreateArgs.append(driver) + print("Network Driver Detected, But Not Supported") + } + if let driverOpts = networkConfig?.driver_opts, !driverOpts.isEmpty { + // for (optKey, optValue) in driverOpts { + // networkCreateArgs.append("--opt") + // networkCreateArgs.append("\(optKey)=\(optValue)") + // } + print("Network Options Detected, But Not Supported") + } + // Add various network flags + if networkConfig?.attachable == true { + // networkCreateArgs.append("--attachable") + print("Network Attachable Flag Detected, But Not Supported") + } + if networkConfig?.enable_ipv6 == true { + // networkCreateArgs.append("--ipv6") + print("Network IPv6 Flag Detected, But Not Supported") + } + if networkConfig?.isInternal == true { + // networkCreateArgs.append("--internal") + print("Network Internal Flag Detected, But Not Supported") + } // CORRECTED: Use isInternal + + // Add labels + if let labels = networkConfig?.labels, !labels.isEmpty { + print("Network Labels Detected, But Not Supported") + // for (labelKey, labelValue) in labels { + // networkCreateArgs.append("--label") + // networkCreateArgs.append("\(labelKey)=\(labelValue)") + // } } - } - } - } - - // MARK: Compose Top Level Functions - - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } - } - - 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 - - let volumeUrl = URL.homeDirectory.appending( - path: ".containers/Volumes/\(projectName)/\(actualVolumeName)") - let volumePath = volumeUrl.path(percentEncoded: false) - - 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." - ) - try? fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) - } - - private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws - { - let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name - - if let externalNetwork = networkConfig?.external, externalNetwork.isExternal { - 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"] - - #warning("Docker Compose Network Options Not Supported") - // Add driver and driver options - if let driver = networkConfig?.driver, !driver.isEmpty { - // networkCreateArgs.append("--driver") - // networkCreateArgs.append(driver) - print("Network Driver Detected, But Not Supported") - } - if let driverOpts = networkConfig?.driver_opts, !driverOpts.isEmpty { - // for (optKey, optValue) in driverOpts { - // networkCreateArgs.append("--opt") - // networkCreateArgs.append("\(optKey)=\(optValue)") - // } - print("Network Options Detected, But Not Supported") - } - // Add various network flags - if networkConfig?.attachable == true { - // networkCreateArgs.append("--attachable") - print("Network Attachable Flag Detected, But Not Supported") - } - if networkConfig?.enable_ipv6 == true { - // networkCreateArgs.append("--ipv6") - print("Network IPv6 Flag Detected, But Not Supported") - } - if networkConfig?.isInternal == true { - // networkCreateArgs.append("--internal") - print("Network Internal Flag Detected, But Not Supported") - } // CORRECTED: Use isInternal - - // Add labels - if let labels = networkConfig?.labels, !labels.isEmpty { - print("Network Labels Detected, But Not Supported") - // for (labelKey, labelValue) in labels { - // networkCreateArgs.append("--label") - // networkCreateArgs.append("\(labelKey)=\(labelValue)") - // } - } - print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") - print( - "Executing container network create: container \(networkCreateArgs.joined(separator: " "))" - ) - guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { - print("Network '\(networkName)' already exists") - return - } - let commands = [actualNetworkName] - - let networkCreate = try Application.NetworkCreate.parse( - commands + logging.passThroughCommands()) - - try await networkCreate.run() - print("Network '\(networkName)' created") - } - } - - // MARK: Compose Service Level Functions - private mutating func configService( - _ service: Service, serviceName: String, from dockerCompose: DockerCompose - ) async throws { - guard let projectName else { throw ComposeError.invalidProjectName } - - var imageToRun: String - - var runCommandArgs: [String] = [] - - // Handle 'build' configuration - if let buildConfig = service.build { - imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) - } else if let img = service.image { - // Use specified image if no build config - // Pull image if necessary - try await pullImage(img, platform: service.platform) - imageToRun = img - } else { - // Should not happen due to Service init validation, but as a fallback - throw ComposeError.imageNotFound(serviceName) - } - - // Set Run Platform - if let platform = service.platform { - runCommandArgs.append(contentsOf: ["--platform", "\(platform)"]) - } - - // Handle 'deploy' configuration (note that this tool doesn't fully support it) - if service.deploy != nil { - print( - "Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully." - ) - print( - "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." - ) - print("The service will be run as a single container based on other configurations.") - } - - // Add detach flag if specified on the CLI - if detach { - runCommandArgs.append("-d") - } - - // Determine container name - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - print("Info: Using explicit container_name: \(containerName)") - } else { - // Default container name based on project and service name - containerName = "\(projectName)-\(serviceName)" - } - runCommandArgs.append("--name") - runCommandArgs.append(containerName) - - // REMOVED: Restart policy is not supported by `container run` - // if let restart = service.restart { - // runCommandArgs.append("--restart") - // runCommandArgs.append(restart) - // } - - // Add user - if let user = service.user { - runCommandArgs.append("--user") - runCommandArgs.append(user) - } - - // Add volume mounts - if let volumes = service.volumes { - for volume in volumes { - let args = try await configVolume(volume) - runCommandArgs.append(contentsOf: args) - } - } + print("Creating network: \(networkName) (Actual name: \(actualNetworkName))") + print("Executing container network create: container \(networkCreateArgs.joined(separator: " "))") + guard (try? await ClientNetwork.get(id: actualNetworkName)) == nil else { + print("Network '\(networkName)' already exists") + return + } + let commands = [actualNetworkName] + + let networkCreate = try Application.NetworkCreate.parse(commands + logging.passThroughCommands()) + + try await networkCreate.run() + print("Network '\(networkName)' created") + } + } + + // MARK: Compose Service Level Functions + private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { + guard let projectName else { throw ComposeError.invalidProjectName } + + var imageToRun: String + + var runCommandArgs: [String] = [] + + // Handle 'build' configuration + if let buildConfig = service.build { + imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName) + } else if let img = service.image { + // Use specified image if no build config + // Pull image if necessary + try await pullImage(img, platform: service.platform) + imageToRun = img + } else { + // Should not happen due to Service init validation, but as a fallback + throw ComposeError.imageNotFound(serviceName) + } + + // Set Run Platform + if let platform = service.platform { + runCommandArgs.append(contentsOf: ["--platform", "\(platform)"]) + } + + // Handle 'deploy' configuration (note that this tool doesn't fully support it) + if service.deploy != nil { + print("Note: The 'deploy' configuration for service '\(serviceName)' was parsed successfully.") + print( + "However, this 'container-compose' tool does not currently support 'deploy' functionality (e.g., replicas, resources, update strategies) as it is primarily for orchestration platforms like Docker Swarm or Kubernetes, not direct 'container run' commands." + ) + print("The service will be run as a single container based on other configurations.") + } + + // Add detach flag if specified on the CLI + if detach { + runCommandArgs.append("-d") + } + + // Determine container name + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + print("Info: Using explicit container_name: \(containerName)") + } else { + // Default container name based on project and service name + containerName = "\(projectName)-\(serviceName)" + } + runCommandArgs.append("--name") + runCommandArgs.append(containerName) + + // REMOVED: Restart policy is not supported by `container run` + // if let restart = service.restart { + // runCommandArgs.append("--restart") + // runCommandArgs.append(restart) + // } + + // Add user + if let user = service.user { + runCommandArgs.append("--user") + runCommandArgs.append(user) + } + + // Add volume mounts + if let volumes = service.volumes { + for volume in volumes { + let args = try await configVolume(volume) + runCommandArgs.append(contentsOf: args) + } + } - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables + // Combine environment variables from .env files and service environment + var combinedEnv: [String: String] = environmentVariables - if let envFiles = service.env_file { - for envFile in envFiles { - let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") - combinedEnv.merge(additionalEnvVars) { (current, _) in current } + if let envFiles = service.env_file { + for envFile in envFiles { + let additionalEnvVars = loadEnvFile(path: "\(cwd)/\(envFile)") + combinedEnv.merge(additionalEnvVars) { (current, _) in current } + } + } + + if let serviceEnv = service.environment { + combinedEnv.merge(serviceEnv) { (old, new) in + guard !new.contains("${") else { + return old + } + return new + } // Service env overrides .env files + } + + // Fill in variables + combinedEnv = combinedEnv.mapValues({ value in + guard value.contains("${") else { return value } + + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combinedEnv[variableName] ?? value + }) + + // Fill in IPs + combinedEnv = combinedEnv.mapValues({ value in + containerIps[value] ?? value + }) + + // MARK: Spinning Spot + // Add environment variables to run command + for (key, value) in combinedEnv { + runCommandArgs.append("-e") + runCommandArgs.append("\(key)=\(value)") + } + + if let ports = service.ports { + for port in ports { + let resolvedPort = resolveVariable(port, with: environmentVariables) + runCommandArgs.append("-p") + runCommandArgs.append(composePortToRunArg(resolvedPort)) + } } - } - if let serviceEnv = service.environment { - combinedEnv.merge(serviceEnv) { (old, new) in - guard !new.contains("${") else { - return old + // Connect to specified networks + if let serviceNetworks = service.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: environmentVariables) + // 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) } - return new - } // Service env overrides .env files - } - - // Fill in variables - combinedEnv = combinedEnv.mapValues({ value in - guard value.contains("${") else { return value } - - let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) - return combinedEnv[variableName] ?? value - }) - - // Fill in IPs - combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value - }) - - // MARK: Spinning Spot - // Add environment variables to run command - for (key, value) in combinedEnv { - runCommandArgs.append("-e") - runCommandArgs.append("\(key)=\(value)") - } - - if let ports = service.ports { - for port in ports { - let resolvedPort = resolveVariable(port, with: environmentVariables) - runCommandArgs.append("-p") - runCommandArgs.append(composePortToRunArg(resolvedPort)) - } - } - - // Connect to specified networks - if let serviceNetworks = service.networks { - for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) - // 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) - } - print( - "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composeFilename)." - ) - print( - "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." - ) - } else { - 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) - } - - // Add working directory - if let workingDir = service.working_dir { - let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) - runCommandArgs.append("--workdir") - runCommandArgs.append(resolvedWorkingDir) - } - - // Add privileged flag - if service.privileged == true { - runCommandArgs.append("--privileged") - } - - // Add read-only flag - if service.read_only == true { - runCommandArgs.append("--read-only") - } - - // Add resource limits - if let cpus = service.deploy?.resources?.limits?.cpus { - runCommandArgs.append(contentsOf: ["--cpus", cpus]) - } - if let memory = service.deploy?.resources?.limits?.memory { - runCommandArgs.append(contentsOf: ["--memory", memory]) - } - - // Handle service-level configs (note: still only parsing/logging, not attaching) - if let serviceConfigs = service.configs { - print( - "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print( - "This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'." - ) - for serviceConfig in serviceConfigs { print( - " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + "Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composeFilename)." ) - } - } - // - // Handle service-level secrets (note: still only parsing/logging, not attaching) - if let serviceSecrets = service.secrets { - print( - "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." - ) - print( - "This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'." - ) - for serviceSecret in serviceSecrets { print( - " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + "Note: This tool assumes custom networks are defined at the top-level 'networks' key or are pre-existing. This tool does not create implicit networks for services if not explicitly defined at the top-level." ) - } - } - - // Add interactive and TTY flags - if service.stdin_open == true { - runCommandArgs.append("-i") // --interactive - } - if service.tty == true { - runCommandArgs.append("-t") // --tty - } - - runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint - - // Add entrypoint or command - if let entrypointParts = service.entrypoint { - runCommandArgs.append("--entrypoint") - runCommandArgs.append(contentsOf: entrypointParts) - } else if let commandParts = service.command { - runCommandArgs.append(contentsOf: commandParts) - } - - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) - != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) - { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } + } else { + 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) + } + + // Add working directory + if let workingDir = service.working_dir { + let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) + runCommandArgs.append("--workdir") + runCommandArgs.append(resolvedWorkingDir) + } + + // Add privileged flag + if service.privileged == true { + runCommandArgs.append("--privileged") + } + + // Add read-only flag + if service.read_only == true { + runCommandArgs.append("--read-only") + } + + // Add resource limits + if let cpus = service.deploy?.resources?.limits?.cpus { + runCommandArgs.append(contentsOf: ["--cpus", cpus]) + } + if let memory = service.deploy?.resources?.limits?.memory { + runCommandArgs.append(contentsOf: ["--memory", memory]) + } + + // Handle service-level configs (note: still only parsing/logging, not attaching) + if let serviceConfigs = service.configs { + print( + "Note: Service '\(serviceName)' defines 'configs'. Docker Compose 'configs' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'configs' definitions but will not create or attach them to containers during 'container run'.") + for serviceConfig in serviceConfigs { + print( + " - Config: '\(serviceConfig.source)' (Target: \(serviceConfig.target ?? "default location"), UID: \(serviceConfig.uid ?? "default"), GID: \(serviceConfig.gid ?? "default"), Mode: \(serviceConfig.mode?.description ?? "default"))" + ) + } + } + // + // Handle service-level secrets (note: still only parsing/logging, not attaching) + if let serviceSecrets = service.secrets { + print( + "Note: Service '\(serviceName)' defines 'secrets'. Docker Compose 'secrets' are primarily used for Docker Swarm deployed stacks and are not directly translatable to 'container run' commands." + ) + print("This tool will parse 'secrets' definitions but will not create or attach them to containers during 'container run'.") + for serviceSecret in serviceSecrets { + print( + " - Secret: '\(serviceSecret.source)' (Target: \(serviceSecret.target ?? "default location"), UID: \(serviceSecret.uid ?? "default"), GID: \(serviceSecret.gid ?? "default"), Mode: \(serviceSecret.mode?.description ?? "default"))" + ) + } + } + + // Add interactive and TTY flags + if service.stdin_open == true { + runCommandArgs.append("-i") // --interactive + } + if service.tty == true { + runCommandArgs.append("-t") // --tty + } + + runCommandArgs.append(imageToRun) // Add the image name as the final argument before command/entrypoint + + // Add entrypoint or command + if let entrypointParts = service.entrypoint { + runCommandArgs.append("--entrypoint") + runCommandArgs.append(contentsOf: entrypointParts) + } else if let commandParts = service.command { + runCommandArgs.append(contentsOf: commandParts) + } + + var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! + + if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { + while containerConsoleColors.values.contains(serviceColor) { + serviceColor = Self.availableContainerConsoleColors.randomElement()! + } + } - self.containerConsoleColors[serviceName] = serviceColor + self.containerConsoleColors[serviceName] = serviceColor - Task { [self, serviceColor] in - @Sendable - func handleOutput(_ output: String) { - print("\(serviceName): \(output)".applyingColor(serviceColor)) - } + Task { [self, serviceColor] in + @Sendable + func handleOutput(_ output: String) { + print("\(serviceName): \(output)".applyingColor(serviceColor)) + } - print("\nStarting service: \(serviceName)") - print("Starting \(serviceName)") - print("----------------------------------------\n") - let _ = try await streamCommand( - "container", args: ["run"] + runCommandArgs, onStdout: handleOutput, - onStderr: handleOutput) - } - - do { - try await waitUntilServiceIsRunning(serviceName) - try await updateEnvironmentWithServiceIP(serviceName) - } catch { - print(error) - } - } - - private func pullImage(_ imageName: String, platform: String?) async throws { - let imageList = try await ClientImage.list() - guard - !imageList.contains(where: { - $0.description.reference.components(separatedBy: "/").last == imageName - }) - else { - return - } - - print("Pulling Image \(imageName)...") - - var commands = [ - imageName - ] - - if let platform { - commands.append(contentsOf: ["--platform", platform]) - } - - let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands()) - try await imagePull.run() - } - - /// Builds Docker Service - /// - /// - Parameters: - /// - buildConfig: The configuration for the build - /// - service: The service you would like to build - /// - serviceName: The fallback name for the image - /// - /// - Returns: Image Name (`String`) - private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) - async throws -> String - { - // Determine image tag for built image - let imageToRun = service.image ?? "\(serviceName):latest" - let imageList = try await ClientImage.list() - if !rebuild, - imageList.contains(where: { - $0.description.reference.components(separatedBy: "/").last == imageToRun - }) - { - return imageToRun - } - - // Build command arguments - var commands = ["\(self.cwd)/\(buildConfig.context)"] - - // Add build arguments - for (key, value) in buildConfig.args ?? [:] { - commands.append(contentsOf: [ - "--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))", - ]) - } - - // Add Dockerfile path - commands.append(contentsOf: [ - "--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")", - ]) - - // Add caching options - if noCache { - commands.append("--no-cache") - } - - // Add OS/Arch - let split = service.platform?.split(separator: "/") - let os = String(split?.first ?? "linux") - let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") - commands.append(contentsOf: ["--os", os]) - commands.append(contentsOf: ["--arch", arch]) - - // Add image name - commands.append(contentsOf: ["--tag", imageToRun]) - - // Add CPU & Memory - let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 - let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" - commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) - commands.append(contentsOf: ["--memory", memoryLimit]) - - let buildCommand = try Application.BuildCommand.parse(commands) - print("\n----------------------------------------") - print("Building image for service: \(serviceName) (Tag: \(imageToRun))") - try buildCommand.validate() - try await buildCommand.run() - print("Image build for \(serviceName) completed.") - print("----------------------------------------") - - return imageToRun - } - - private func configVolume(_ volume: String) async throws -> [String] { - let resolvedVolume = resolveVariable(volume, with: environmentVariables) - - var runCommandArgs: [String] = [] - - // Parse the volume string: destination[:mode] - let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) - - guard components.count >= 2 else { - print( - "Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping." - ) - return [] - } - - let source = components[0] - let destination = components[1] - - // Check if the source looks like a host path (contains '/' or starts with '.') - // This heuristic helps distinguish bind mounts from named volume references. - if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - // This is likely a bind mount (local path to container path) - var isDirectory: ObjCBool = false - // Ensure the path is absolute or relative to the current directory for FileManager - let fullHostPath = - (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) - - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + print("\nStarting service: \(serviceName)") + print("Starting \(serviceName)") + print("----------------------------------------\n") + let _ = try await streamCommand("container", args: ["run"] + runCommandArgs, onStdout: handleOutput, onStderr: handleOutput) + } + + do { + try await waitUntilServiceIsRunning(serviceName) + try await updateEnvironmentWithServiceIP(serviceName) + } catch { + print(error) + } + } + + private func pullImage(_ imageName: String, platform: String?) async throws { + let imageList = try await ClientImage.list() + guard !imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageName }) else { + return + } + + print("Pulling Image \(imageName)...") + + var commands = [ + imageName + ] + + if let platform { + commands.append(contentsOf: ["--platform", platform]) + } + + let imagePull = try Application.ImagePull.parse(commands + logging.passThroughCommands()) + try await imagePull.run() + } + + /// Builds Docker Service + /// + /// - Parameters: + /// - buildConfig: The configuration for the build + /// - service: The service you would like to build + /// - serviceName: The fallback name for the image + /// + /// - Returns: Image Name (`String`) + private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String { + // Determine image tag for built image + let imageToRun = service.image ?? "\(serviceName):latest" + let imageList = try await ClientImage.list() + if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) { + return imageToRun + } + + // Build command arguments + var commands = ["\(self.cwd)/\(buildConfig.context)"] + + // Add build arguments + for (key, value) in buildConfig.args ?? [:] { + commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"]) + } + + // Add Dockerfile path + commands.append(contentsOf: ["--file", "\(self.cwd)/\(buildConfig.dockerfile ?? "Dockerfile")"]) + + // Add caching options + if noCache { + commands.append("--no-cache") + } + + // Add OS/Arch + let split = service.platform?.split(separator: "/") + let os = String(split?.first ?? "linux") + let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") + commands.append(contentsOf: ["--os", os]) + commands.append(contentsOf: ["--arch", arch]) + + // Add image name + commands.append(contentsOf: ["--tag", imageToRun]) + + // Add CPU & Memory + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" + commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) + commands.append(contentsOf: ["--memory", memoryLimit]) + + let buildCommand = try Application.BuildCommand.parse(commands) + print("\n----------------------------------------") + print("Building image for service: \(serviceName) (Tag: \(imageToRun))") + try buildCommand.validate() + try await buildCommand.run() + print("Image build for \(serviceName) completed.") + print("----------------------------------------") + + return imageToRun + } + + private func configVolume(_ volume: String) async throws -> [String] { + let resolvedVolume = resolveVariable(volume, with: environmentVariables) + + var runCommandArgs: [String] = [] + + // Parse the volume string: destination[:mode] + let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init) + + guard components.count >= 2 else { + print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.") + return [] + } + + let source = components[0] + let destination = components[1] + + // Check if the source looks like a host path (contains '/' or starts with '.') + // This heuristic helps distinguish bind mounts from named volume references. + if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { + // This is likely a bind mount (local path to container path) + var isDirectory: ObjCBool = false + // Ensure the path is absolute or relative to the current directory for FileManager + let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + + if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if isDirectory.boolValue { + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } else { + // Host path exists but is a file + print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") + } } else { - // Host path exists but is a file - print( - "Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume." - ) + // Host path does not exist, assume it's meant to be a directory and try to create it. + do { + try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(fullHostPath)") + runCommandArgs.append("-v") + runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + } catch { + print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + } } - } else { - // Host path does not exist, assume it's meant to be a directory and try to create it. - do { - try fileManager.createDirectory( - atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") - runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument - } catch { - print( - "Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume." - ) - } - } - } 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) - - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument - } - - return runCommandArgs - } + } 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) + + // Host path exists and is a directory, add the volume + runCommandArgs.append("-v") + // Reconstruct the volume string without mode, ensuring it's source:destination + runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + } + + return runCommandArgs + } } // MARK: CommandLine Functions extension ComposeUp { - /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. - /// - /// - Parameters: - /// - command: The name of the command to run (e.g., `"container"`). - /// - args: Command-line arguments to pass to the command. - /// - onStdout: Closure called with streamed stdout data. - /// - onStderr: Closure called with streamed stderr data. - /// - Returns: The process's exit code. - /// - Throws: If the process fails to launch. - @discardableResult - func streamCommand( - _ command: String, - args: [String] = [], - onStdout: @escaping (@Sendable (String) -> Void), - onStderr: @escaping (@Sendable (String) -> Void) - ) async throws -> Int32 { - try await withCheckedThrowingContinuation { continuation in - let process = Process() - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [command] + args - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - process.environment = ProcessInfo.processInfo.environment.merging([ - "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" - ]) { _, new in new } - - let stdoutHandle = stdoutPipe.fileHandleForReading - let stderrHandle = stderrPipe.fileHandleForReading - - stdoutHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStdout(string) + /// Runs a command, streams stdout and stderr via closures, and completes when the process exits. + /// + /// - Parameters: + /// - command: The name of the command to run (e.g., `"container"`). + /// - args: Command-line arguments to pass to the command. + /// - onStdout: Closure called with streamed stdout data. + /// - onStderr: Closure called with streamed stderr data. + /// - Returns: The process's exit code. + /// - Throws: If the process fails to launch. + @discardableResult + func streamCommand( + _ command: String, + args: [String] = [], + onStdout: @escaping (@Sendable (String) -> Void), + onStderr: @escaping (@Sendable (String) -> Void) + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [command] + args + process.currentDirectoryURL = URL(fileURLWithPath: cwd) + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + process.environment = ProcessInfo.processInfo.environment.merging([ + "PATH": "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin" + ]) { _, new in new } + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStdout(string) + } } - } - stderrHandle.readabilityHandler = { handle in - let data = handle.availableData - guard !data.isEmpty else { return } - if let string = String(data: data, encoding: .utf8) { - onStderr(string) + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let string = String(data: data, encoding: .utf8) { + onStderr(string) + } } - } - process.terminationHandler = { proc in - stdoutHandle.readabilityHandler = nil - stderrHandle.readabilityHandler = nil - continuation.resume(returning: proc.terminationStatus) - } + process.terminationHandler = { proc in + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + continuation.resume(returning: proc.terminationStatus) + } - do { - try process.run() - } catch { - continuation.resume(throwing: error) - } - } - } + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + } } diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 5e1d829..0dfd152 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -21,37 +21,37 @@ // Created by Morris Richman on 6/17/25. // -import ContainerCommands import Foundation -import Rainbow import Yams +import Rainbow +import ContainerCommands /// Loads environment variables from a .env file. /// - Parameter path: The full path to the .env file. /// - Returns: A dictionary of key-value pairs representing environment variables. public func loadEnvFile(path: String) -> [String: String] { - var envVars: [String: String] = [:] - let fileURL = URL(fileURLWithPath: path) - do { - let content = try String(contentsOf: fileURL, encoding: .utf8) - let lines = content.split(separator: "\n") - for line in lines { - let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines) - // Ignore empty lines and comments - if !trimmedLine.isEmpty && !trimmedLine.starts(with: "#") { - // Parse key=value pairs - if let eqIndex = trimmedLine.firstIndex(of: "=") { - let key = String(trimmedLine[.. [String: String] { /// - envVars: A dictionary of environment variables to use for resolution. /// - Returns: The string with all recognized environment variables resolved. public func resolveVariable(_ value: String, with envVars: [String: String]) -> String { - var resolvedValue = value - // Regex to find ${VAR}, ${VAR:-default}, ${VAR:?error} - let regex = try! NSRegularExpression( - pattern: #"\$\{([A-Za-z0-9_]+)(:?-(.*?))?(:\?(.*?))?\}"#, options: []) - - // Combine process environment with loaded .env file variables, prioritizing process environment - let combinedEnv = ProcessInfo.processInfo.environment.merging(envVars) { (current, _) in current - } - - // Loop to resolve all occurrences of variables in the string - while let match = regex.firstMatch( - in: resolvedValue, options: [], - range: NSRange(resolvedValue.startIndex.. /// - Parameter cwd: The current working directory path. /// - Returns: A sanitized project name suitable for container naming. public func deriveProjectName(cwd: String) -> String { - // We need to replace '.' with _ because it is not supported in the container name - let projectName = URL(fileURLWithPath: cwd).lastPathComponent.replacingOccurrences( - of: ".", with: "_") - return projectName + // We need to replace '.' with _ because it is not supported in the container name + let projectName = URL(fileURLWithPath: cwd).lastPathComponent.replacingOccurrences(of: ".", with: "_") + return projectName } /// Converts Docker Compose port specification into a container run -p format. @@ -122,54 +109,55 @@ public func deriveProjectName(cwd: String) -> String { /// - Parameter portSpec: The port specification string from docker-compose.yml. /// - Returns: A properly formatted port binding for `container run -p`. public func composePortToRunArg(_ portSpec: String) -> String { - // Check for protocol suffix (e.g., "/tcp" or "/udp") - var protocolSuffix = "" - var portBody = portSpec - if let slashRange = portSpec.range(of: "/", options: [.backwards]) { - let afterSlash = portSpec[slashRange.lowerBound...] - let protocolPart = String(afterSlash) - if protocolPart == "/tcp" || protocolPart == "/udp" { - protocolSuffix = protocolPart - portBody = String(portSpec[.. Date: Fri, 6 Mar 2026 08:09:38 +0300 Subject: [PATCH 3/3] test: add dynamic coverage for explicit IP port mapping Verify compose up/down behavior with an explicit host IP port binding so the runtime path is covered end to end. --- .../ComposeUpTests.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 779b789..08b1cb1 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -267,6 +267,46 @@ struct ComposeUpTests { #expect(appContainer.configuration.resources.cpus == 1) #expect(appContainer.configuration.resources.memoryInBytes == 512.mib()) } + + @Test("Test compose up with explicit IP port mapping") + func testComposeUpWithExplicitIPPortMapping() async throws { + let yaml = """ + version: "3.8" + services: + web: + image: nginx:alpine + ports: + - "127.0.0.1:18081:80" + """ + + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + try await composeUp.run() + + var containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(project.name) + }) + + guard let webContainer = containers.first(where: { $0.configuration.id == "\(project.name)-web" }) else { + throw Errors.containerNotFound + } + + #expect(webContainer.status == .running) + #expect(webContainer.configuration.publishedPorts.map({ "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" }) == ["127.0.0.1:18081:80"]) + + var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + try await composeDown.run() + + containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(project.name) + }) + + #expect(containers.count == 1) + #expect(containers.filter({ $0.status == .stopped }).count == 1) + } enum Errors: Error { case containerNotFound