diff --git a/README.md b/README.md index 480fe1ad..3c3cd0b9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Container-Compose brings (limited) Docker Compose support to [Apple Container](h - **Environment configuration:** Support for environment variable files (`.env`) to customize deployments. - **Service dependencies:** Specify service dependencies and startup order. - **Volume and network mapping:** Map data and networking as specified in Compose files to Apple Container equivalents. +- **Resource limits:** Map the official `deploy.resources` limits (and the legacy `cpus`/`mem_limit`/`mem_reservation` shorthands, with `deploy` winning on conflict) plus `shm_size`, `tmpfs`, and `ulimits` onto Apple Container flags. Fractional CPU requests are rounded up (Apple Container takes whole CPUs), and unsupported keys (soft reservations, device/GPU reservations, replicas) are ignored with a warning. - **Extensible:** Designed for future extension and customization. ## Getting Started @@ -63,6 +64,15 @@ container-compose up You may need to provide a path to your `docker-compose.yml` and `.env` file as arguments. +Run a one-off command on a service (similar to `docker compose run`): + +```sh +container-compose run --rm web sh +``` + +The service's ports are not mapped unless `--service-ports` is given, and its `depends_on` +services are started first unless `--no-deps` is given. + ## Contributing Contributions are welcome! Please open issues or submit pull requests to help improve this project. diff --git a/Sample Compose Files/Healthchecked Redis/docker-compose.yaml b/Sample Compose Files/Healthchecked Redis/docker-compose.yaml index d8c5ce6b..0e718840 100644 --- a/Sample Compose Files/Healthchecked Redis/docker-compose.yaml +++ b/Sample Compose Files/Healthchecked Redis/docker-compose.yaml @@ -1,7 +1,21 @@ +# Explicit project name: the sample's directory name contains a space, which +# Apple Container does not allow in generated container/volume names. +name: healthchecked-redis + services: redis: restart: always image: redis:7-alpine + deploy: + resources: + limits: + cpus: "2" + memory: 512m + shm_size: 256m + tmpfs: + - /tmp + ulimits: + nofile: 65535 volumes: - redis-data:/data healthcheck: @@ -10,3 +24,6 @@ services: retries: 20 ports: - "6379:6379" + +volumes: + redis-data: diff --git a/Sources/Container-Compose/Application.swift b/Sources/Container-Compose/Application.swift index 1a7b639c..3b2cacd9 100644 --- a/Sources/Container-Compose/Application.swift +++ b/Sources/Container-Compose/Application.swift @@ -29,6 +29,7 @@ public struct Main: AsyncParsableCommand { version: Self.versionString, subcommands: [ ComposeUp.self, + ComposeRun.self, ComposeDown.self, ComposeBuild.self, Version.self diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index b0950efe..64695caf 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -111,9 +111,34 @@ public struct Service: Codable, Hashable { public let tty: Bool? /// Memory limit shorthand (e.g., "512m", "1g") — top-level alternative to - /// `deploy.resources.limits.memory`. Takes precedence when both are set. + /// `deploy.resources.limits.memory`. The `deploy` form wins when both are set. public let mem_limit: String? + /// CPU limit shorthand (e.g., "1.5", "2") — top-level alternative to + /// `deploy.resources.limits.cpus`. The `deploy` form wins when both are set. + public let cpus: String? + + /// Memory reservation shorthand (e.g., "256m") — top-level alternative to + /// `deploy.resources.reservations.memory`. Apple Container has no soft + /// memory limit, so `ResourceArguments` approximates it as the hard + /// `--memory` limit when no limit is otherwise configured. + public let mem_reservation: String? + + /// Size of `/dev/shm` (e.g., "64m", "1g"). Passed through as `--shm-size`. + public let shm_size: String? + + /// tmpfs mounts (e.g., `["/run", "/tmp"]`). Each entry is passed through + /// as `--tmpfs `. + public let tmpfs: [String]? + + /// Resource limits keyed by type (e.g., `nofile: 65535`). Passed through as + /// `--ulimit =[:]`. + public let ulimits: [String: Ulimit]? + + /// Device mappings (e.g., `["/dev/kvm:/dev/kvm"]`). Parsed only so callers + /// can warn that Apple Container has no `--device` equivalent. + public let devices: [String]? + /// Additional `/etc/hosts` entries injected into the container. Each entry is a /// `"hostname:IP"` string. The special token `host-gateway` resolves to the host /// machine's IP as seen from inside the container. @@ -133,7 +158,7 @@ public struct Service: Codable, Hashable { enum CodingKeys: String, CodingKey { case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user, container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, - mem_limit, extra_hosts, profiles + mem_limit, cpus, mem_reservation, shm_size, tmpfs, ulimits, devices, extra_hosts, profiles } /// Public memberwise initializer for testing @@ -166,6 +191,12 @@ public struct Service: Codable, Hashable { stdin_open: Bool? = nil, tty: Bool? = nil, mem_limit: String? = nil, + cpus: String? = nil, + mem_reservation: String? = nil, + shm_size: String? = nil, + tmpfs: [String]? = nil, + ulimits: [String: Ulimit]? = nil, + devices: [String]? = nil, extra_hosts: [String]? = nil, profiles: [String]? = nil, dependedBy: [String] = [] @@ -198,6 +229,12 @@ public struct Service: Codable, Hashable { self.stdin_open = stdin_open self.tty = tty self.mem_limit = mem_limit + self.cpus = cpus + self.mem_reservation = mem_reservation + self.shm_size = shm_size + self.tmpfs = tmpfs + self.ulimits = ulimits + self.devices = devices self.extra_hosts = extra_hosts self.profiles = profiles self.dependedBy = dependedBy @@ -321,14 +358,26 @@ public struct Service: Codable, Hashable { stdin_open = try container.decodeIfPresent(Bool.self, forKey: .stdin_open) tty = try container.decodeIfPresent(Bool.self, forKey: .tty) platform = try container.decodeIfPresent(String.self, forKey: .platform) - if let s = try? container.decodeIfPresent(String.self, forKey: .mem_limit) { - mem_limit = s - } else if let i = try? container.decodeIfPresent(Int.self, forKey: .mem_limit) { - mem_limit = "\(i)" + mem_limit = Self.decodeScalarString(container, forKey: .mem_limit) + cpus = Self.decodeScalarString(container, forKey: .cpus) + mem_reservation = Self.decodeScalarString(container, forKey: .mem_reservation) + shm_size = Self.decodeScalarString(container, forKey: .shm_size) + + // `tmpfs` accepts either a single path or a list of paths per the + // Compose spec (`tmpfs: /run` vs `tmpfs: [/run, /tmp]`). Apple's + // `container run --tmpfs` takes only a path, so the long form's + // `size`/`mode` options (when given as a mapping) are ignored. + if let list = try? container.decodeIfPresent([String].self, forKey: .tmpfs) { + tmpfs = list + } else if let single = try? container.decodeIfPresent(String.self, forKey: .tmpfs) { + tmpfs = [single] } else { - mem_limit = nil + tmpfs = nil } + ulimits = try container.decodeIfPresent([String: Ulimit].self, forKey: .ulimits) + devices = try container.decodeIfPresent([String].self, forKey: .devices) + // `extra_hosts` accepts two forms per the Compose spec: // extra_hosts: extra_hosts: // - "hostname:IP" or hostname: IP @@ -347,6 +396,25 @@ public struct Service: Codable, Hashable { profiles = try container.decodeIfPresent([String].self, forKey: .profiles) } + /// Decodes a scalar that Compose permits as either a string or a number + /// (e.g. `mem_limit: 512m`, `mem_limit: 536870912`, `cpus: 1.5`) into its + /// string form so the value can be passed through to `container run`. + static func decodeScalarString( + _ container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> String? { + if let string = try? container.decodeIfPresent(String.self, forKey: key) { + return string + } + if let int = try? container.decodeIfPresent(Int.self, forKey: key) { + return "\(int)" + } + if let double = try? container.decodeIfPresent(Double.self, forKey: key) { + return double.truncatingRemainder(dividingBy: 1) == 0 ? "\(Int(double))" : "\(double)" + } + return nil + } + /// True when this service should be included by default given the currently /// active profiles. Per the Compose spec: a service with no `profiles` is /// always eligible; a service with `profiles` is eligible only when at least diff --git a/Sources/Container-Compose/Codable Structs/Ulimit.swift b/Sources/Container-Compose/Codable Structs/Ulimit.swift new file mode 100644 index 00000000..eb874f39 --- /dev/null +++ b/Sources/Container-Compose/Codable Structs/Ulimit.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// A single `ulimits:` entry from a Compose service, e.g. +/// +/// ulimits: +/// nofile: 65535 +/// nproc: +/// soft: 1024 +/// hard: 2048 +/// +/// Values may be bare integers, numeric strings, or a `{soft, hard}` mapping. +/// Stored as strings so unresolved environment-variable expressions survive +/// decoding and can be resolved (and passed through to `--ulimit`) later. +public struct Ulimit: Codable, Hashable { + public let soft: String? + public let hard: String? + + enum CodingKeys: String, CodingKey { + case soft, hard + } + + public init(soft: String? = nil, hard: String? = nil) { + self.soft = soft + self.hard = hard + } + + public init(from decoder: Decoder) throws { + // Short form: `nofile: 65535` (or "65535") — soft and hard are equal. + let single = try decoder.singleValueContainer() + if let value = Self.decodeScalar(from: single) { + soft = value + hard = value + return + } + + // Long form: `nofile: { soft: 1024, hard: 2048 }`. + let container = try decoder.container(keyedBy: CodingKeys.self) + soft = Self.decodeScalar(container, forKey: .soft) + hard = Self.decodeScalar(container, forKey: .hard) + guard soft != nil || hard != nil else { + throw DecodingError.dataCorruptedError( + forKey: .soft, + in: container, + debugDescription: "ulimit entry must be a number, a string, or a {soft, hard} mapping." + ) + } + } + + public func encode(to encoder: Encoder) throws { + if soft == hard { + var single = encoder.singleValueContainer() + try single.encode(soft) + return + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(soft, forKey: .soft) + try container.encodeIfPresent(hard, forKey: .hard) + } + + /// Decodes a scalar rlimit value, accepting numbers and numeric strings. + private static func decodeScalar(from single: SingleValueDecodingContainer) -> String? { + if let string = try? single.decode(String.self) { + return string + } + if let int = try? single.decode(Int.self) { + return "\(int)" + } + if let double = try? single.decode(Double.self) { + return normalizedNumber(double) + } + return nil + } + + private static func decodeScalar( + _ container: KeyedDecodingContainer, + forKey key: CodingKeys + ) -> String? { + if let string = try? container.decodeIfPresent(String.self, forKey: key) { + return string + } + if let int = try? container.decodeIfPresent(Int.self, forKey: key) { + return "\(int)" + } + if let double = try? container.decodeIfPresent(Double.self, forKey: key) { + return normalizedNumber(double) + } + return nil + } + + private static func normalizedNumber(_ value: Double) -> String { + if value.truncatingRemainder(dividingBy: 1) == 0 { + return "\(Int(value))" + } + return "\(value)" + } +} diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 56d58818..8b0a5602 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -109,7 +109,7 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable { let arch = String(((split ?? []).count >= 1 ? split?.last : nil) ?? "arm64") commands.append(contentsOf: ["--os", os, "--arch", arch]) - let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? service.cpus ?? "2") ?? 2 let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" commands.append(contentsOf: ["--cpus", "\(cpuCount)", "--memory", memoryLimit]) diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index de25845a..b04f0fad 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -98,5 +98,23 @@ public struct ComposeDown: AsyncParsableCommand { // to remove once the container that had it mounted is stopped. try? fileManager.removeItem(atPath: ComposeUp.extraHostsFilePath(projectName: project.projectName, serviceName: target.serviceName)) } + + await removeOrphanedOneOffHostsFiles(projectName: project.projectName) + } + + /// Sweeps the generated /etc/hosts files for one-off `run` containers whose + /// container no longer exists. Files still backing a running or stopped + /// container are kept, since that container may be started again. + private func removeOrphanedOneOffHostsFiles(projectName: String) async { + let client = ContainerClient() + let tmpDirectory = NSTemporaryDirectory() + let entries = (try? fileManager.contentsOfDirectory(atPath: tmpDirectory)) ?? [] + for entry in entries { + guard let containerName = ComposeUp.runContainerName(fromHostsFilename: entry, projectName: projectName) else { + continue + } + guard (try? await client.get(id: containerName)) == nil else { continue } + try? fileManager.removeItem(atPath: tmpDirectory + entry) + } } } diff --git a/Sources/Container-Compose/Commands/ComposeRun.swift b/Sources/Container-Compose/Commands/ComposeRun.swift new file mode 100644 index 00000000..befca0b2 --- /dev/null +++ b/Sources/Container-Compose/Commands/ComposeRun.swift @@ -0,0 +1,551 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerCommands +import ContainerAPIClient +import Foundation + +public struct ComposeRun: AsyncParsableCommand, @unchecked Sendable { + public init() {} + + public static let configuration: CommandConfiguration = .init( + commandName: "run", + abstract: "Run a one-off command on a service", + discussion: """ + The trailing COMMAND overrides the service command. The service ports + are not mapped unless --service-ports is given. Dependencies are + started detached first unless --no-deps is given. + + -e/--env, -u/--user, -i/--interactive and -t/--tty are shared process + flags with the same meaning as docker run. There is no -w/--workdir + override: -w is reserved for the local working directory, so set + working_dir in the compose file instead. + """ + ) + + @Argument(help: "Service to run a one-off command on") + var service: String + + @Argument(parsing: .remaining, help: "Command and arguments overriding the service command") + var command: [String] = [] + + @Flag(name: [.customShort("d"), .customLong("detach")], help: "Run container in background") + var detach: Bool = false + + @Flag(name: .long, help: "Automatically remove the container when it exits") + var rm: Bool = false + + @Flag(name: .customLong("no-deps"), help: "Don't start linked services") + var noDeps: Bool = false + + @Flag(name: [.customShort("P"), .customLong("service-ports")], help: "Map the service ports to the host") + var servicePorts: Bool = false + + @Option(name: [.customShort("p"), .customLong("publish")], help: "Publish a container port to the host") + var publish: [String] = [] + + @Option(name: .customLong("env-from-file"), help: "Read environment variables from a file") + var envFromFile: [String] = [] + + @Option(name: [.short, .customLong("volume")], help: "Bind mount a volume (source:destination[:mode])") + var volume: [String] = [] + + @Option(name: [.short, .customLong("label")], help: "Add a label (key=value)") + var label: [String] = [] + + @Option(name: .customLong("entrypoint"), help: "Override the image entrypoint") + var entrypoint: String? + + @Option(name: .long, help: "Assign a name to the one-off container") + var name: String? + + @Flag(name: .long, help: "Build the image before running") + var build: Bool = false + + @Flag(name: .long, help: "Do not use cache when building") + var noCache: Bool = false + + @Flag(name: [.customShort("T"), .customLong("no-tty")], help: "Disable pseudo-TTY allocation") + var noTTY: Bool = false + + @OptionGroup + var projectOptions: ComposeProjectOptions + + @OptionGroup + var logging: Flags.Logging + + private var composeDirectory: String { projectOptions.composeDirectory } + private var cwd: String { projectOptions.cwd } + + func baseArgs() -> [String] { + var args: [String] = [] + if let file = projectOptions.composeFileOptions.composeFilename { + args.append(contentsOf: ["-f", file]) + } + for profile in projectOptions.composeFileOptions.profile { + args.append(contentsOf: ["--profile", profile]) + } + if let cwd = projectOptions.process.cwd { + args.append(contentsOf: ["--workdir", cwd]) + } + for envFile in projectOptions.process.envFile { + args.append(contentsOf: ["--env-file", envFile]) + } + if projectOptions.process.interactive { + args.append("-i") + } + if projectOptions.process.tty { + args.append("-t") + } + if let user = projectOptions.process.user { + args.append(contentsOf: ["-u", user]) + } + for env in projectOptions.process.env { + args.append(contentsOf: ["-e", env]) + } + if logging.debug { + args.append("--debug") + } + return args + } + + public mutating func run() async throws { + let project = try projectOptions.resolve(filteringBy: [service]) + let dockerCompose = project.compose + let projectName = project.projectName + let baseEnv = loadEnvFile(path: projectOptions.envFilePath) + + let allServices: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap { name, svc in + guard let svc else { return nil } + return (name, svc) + } + try Service.validateRequestedServices([service], against: allServices) + guard let target = project.services.first(where: { $0.serviceName == service }) else { + throw ComposeError.noSuchService(service) + } + let svc = target.service + let deps = project.services.filter { $0.serviceName != service } + + var depIPs: [String: String] = [:] + if !noDeps, !deps.isEmpty { + var pending: [String] = [] + for dep in deps where !(await Self.containerRunning(names: dep.candidateContainerNames)) { + pending.append(dep.serviceName) + } + if !pending.isEmpty { + print("Starting dependencies: \(pending.joined(separator: ", "))") + var up = try ComposeUp.parse(baseArgs() + ["-d"] + pending) + try await up.run() + } + for dep in deps { + if let ip = await Self.firstIPv4(names: dep.candidateContainerNames) { + depIPs[dep.serviceName] = ip + } + } + } + + var imageToRun: String + if svc.build != nil { + let tag = svc.image ?? "\(service):latest" + let imageMissing = try await !Self.imageExistsLocally(tag) + if build || imageMissing { + var buildArgs = baseArgs() + [service] + if noCache { + buildArgs.insert("--no-cache", at: 0) + } + var buildCommand = try ComposeBuild.parse(buildArgs) + try await buildCommand.run() + } + imageToRun = tag + } else if let img = svc.image { + try await Self.pullImageIfMissing(img, platform: svc.platform, logging: logging) + imageToRun = img + } else { + throw ComposeError.imageNotFound(service) + } + + if let networks = dockerCompose.networks { + for (networkName, networkConfig) in networks { + try await Self.ensureNetwork( + name: networkConfig?.name ?? networkName, + external: networkConfig?.external?.isExternal == true, + logging: logging + ) + } + } + if let volumes = dockerCompose.volumes { + for (volumeName, volumeConfig) in volumes { + guard let volumeConfig else { continue } + try await Self.ensureVolume(name: volumeName, config: volumeConfig, projectName: projectName) + } + } + + var dnsDomain: String? + var dnsAvailable = false + if let derived = ComposeProject.sanitizeDnsDomain(projectName) { + dnsDomain = derived + dnsAvailable = await Self.dnsDomainRegistered(derived) + } + + var runCommandArgs: [String] = [] + var oneOffHostsFilePath: String? + if let platform = svc.platform { + runCommandArgs.append(contentsOf: ["--platform", platform]) + } + if detach { + runCommandArgs.append("-d") + } + if rm { + runCommandArgs.append("--rm") + } + let runName = name ?? Self.oneOffContainerName(projectName: projectName, serviceName: service) + runCommandArgs.append(contentsOf: ["--name", runName]) + if dnsAvailable, let dnsDomain { + runCommandArgs.append(contentsOf: ["--dns-domain", dnsDomain]) + } + + var labels = svc.labels ?? [:] + labels.merge(Self.parseLabelOverrides(label)) { _, new in new } + labels["com.docker.compose.project"] = projectName + labels["com.docker.compose.service"] = service + labels["com.docker.compose.oneoff"] = "True" + for key in labels.keys.sorted() { + runCommandArgs.append(contentsOf: ["--label", "\(key)=\(labels[key] ?? "")"]) + } + + if let effectiveUser = projectOptions.process.user ?? svc.user { + runCommandArgs.append(contentsOf: ["--user", effectiveUser]) + } + + if let volumes = svc.volumes { + for entry in volumes { + runCommandArgs.append(contentsOf: try composeVolumeToRunArgs( + entry, cwd: cwd, environmentVariables: baseEnv, + projectName: projectName, volumeDefinitions: dockerCompose.volumes + )) + } + } + for entry in volume { + runCommandArgs.append(contentsOf: try composeVolumeToRunArgs( + entry, cwd: cwd, environmentVariables: baseEnv, + projectName: projectName, volumeDefinitions: dockerCompose.volumes + )) + } + + var combinedEnv = Self.baseEnvironment(service: svc, composeDirectory: composeDirectory, baseEnv: baseEnv) + for file in envFromFile { + let vars = loadEnvFile(path: resolvedPath(for: file, relativeTo: URL(fileURLWithPath: composeDirectory))) + combinedEnv.merge(vars) { _, new in new } + } + combinedEnv.merge(Self.parseEnvOverrides(projectOptions.process.env, base: combinedEnv)) { _, new in new } + combinedEnv = combinedEnv.mapValues { depIPs[$0] ?? $0 } + for (key, value) in combinedEnv { + runCommandArgs.append(contentsOf: ["-e", "\(key)=\(value)"]) + } + + if servicePorts, let ports = svc.ports { + for port in ports { + runCommandArgs.append(contentsOf: ["-p", composePortToRunArg(resolveVariable(port, with: baseEnv))]) + } + } + for spec in publish { + runCommandArgs.append(contentsOf: ["-p", composePortToRunArg(resolveVariable(spec, with: baseEnv))]) + } + + if let serviceNetworks = svc.networks { + for network in serviceNetworks { + let resolvedNetwork = resolveVariable(network, with: baseEnv) + let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork + let translation = ComposeUp.networkRunArg( + network: networkToConnect, + aliases: svc.networkConfigurations?[network]?.aliases ?? [], + serviceName: service, + environmentVariables: baseEnv + ) + runCommandArgs.append(contentsOf: ["--network", translation.arg]) + if let warning = translation.warning { + print(warning) + } + } + } + + let hostnameTranslation = ComposeUp.hostnameRunArgs( + hostname: svc.hostname, serviceName: service, environmentVariables: baseEnv + ) + if let warning = hostnameTranslation.warning { + print(warning) + } + + if let extraHosts = svc.extra_hosts, !extraHosts.isEmpty { + let resolvedEntries = extraHosts.map { resolveVariable($0, with: baseEnv) } + let needsGateway = resolvedEntries.contains { $0.hasSuffix(":host-gateway") } + let resolvedNetworkName = svc.networks?.first.map { resolveVariable($0, with: baseEnv) } ?? "default" + let hostGatewayIP = needsGateway ? ComposeUp.resolveHostGatewayIP(networkName: resolvedNetworkName) : "" + var hostsFileLines = ["127.0.0.1 localhost", "::1 localhost"] + var seenHostnames: Set = ["localhost"] + for resolved in resolvedEntries { + let parts = resolved.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { continue } + hostsFileLines.append("\(parts[1] == "host-gateway" ? hostGatewayIP : parts[1]) \(parts[0])") + seenHostnames.insert(parts[0]) + } + let ownHostname = svc.hostname.map { resolveVariable($0, with: baseEnv) } ?? runName + if !seenHostnames.contains(ownHostname) { + hostsFileLines.append("127.0.0.1 \(ownHostname)") + } + let hostsFilePath = ComposeUp.runExtraHostsFilePath(projectName: projectName, containerName: runName) + do { + try (hostsFileLines.joined(separator: "\n") + "\n").write(toFile: hostsFilePath, atomically: true, encoding: .utf8) + runCommandArgs.append(contentsOf: ["-v", "\(hostsFilePath):/etc/hosts"]) + oneOffHostsFilePath = hostsFilePath + } catch { + print("Warning: could not write hosts file for service '\(service)' extra_hosts at \(hostsFilePath): \(error.localizedDescription)") + } + } + + if let workingDir = svc.working_dir { + runCommandArgs.append(contentsOf: ["--workdir", resolveVariable(workingDir, with: baseEnv)]) + } + if svc.privileged == true { + print("Note: Service '\(service)' requests privileged mode; Apple Container does not support --privileged; ignoring.") + } + if svc.read_only == true { + runCommandArgs.append("--read-only") + } + let resources = ResourceArguments.runArgs( + for: svc, + serviceName: service, + environmentVariables: baseEnv + ) + runCommandArgs.append(contentsOf: resources.args) + for note in resources.notes { + print(note) + } + + let interactive = !detach && (projectOptions.process.interactive || (svc.stdin_open ?? true)) + let stdinIsTTY = isatty(STDIN_FILENO) != 0 + let useTTY = interactive && !noTTY && (projectOptions.process.tty || (svc.tty ?? stdinIsTTY)) + if interactive { + runCommandArgs.append("-i") + } + if useTTY { + runCommandArgs.append("-t") + } + + let argv = ComposeUp.entrypointAndCommandArgs( + entrypoint: entrypoint.map { [$0] } ?? svc.entrypoint, + command: command.isEmpty ? svc.command : command + ) + if let entrypointFlag = argv.entrypointFlag { + runCommandArgs.append(contentsOf: ["--entrypoint", entrypointFlag]) + } + runCommandArgs.append(imageToRun) + runCommandArgs.append(contentsOf: argv.positional) + + try await Self.reclaimRunName(runName, rm: rm) + + let containerArgs = runCommandArgs + logging.passThroughCommands() + if detach { + print("Running one-off command on service '\(service)' in background as '\(runName)'") + } + let containerRun = try Application.ContainerRun.parse(containerArgs) + do { + try await containerRun.run() + } catch { + // Detached failures can leave the container auto-removing, so only + // delete the file if no container claims it; foreground runs know + // exactly when the container has finished. + await Self.cleanupOneOffHostsFile( + oneOffHostsFilePath, + containerName: runName, + autoRemove: rm && !detach + ) + throw error + } + if !detach { + await Self.cleanupOneOffHostsFile(oneOffHostsFilePath, containerName: runName, autoRemove: rm) + } + } + + /// Removes a one-off run's generated /etc/hosts file once it can no longer + /// be needed: immediately when the container auto-removes (`--rm`), + /// otherwise only if the container is gone. A stopped container may be + /// started again and still needs its bind-mounted file, so those are left + /// for `ComposeDown`'s sweep. Detached runs exit the CLI immediately and + /// likewise rely on the sweep. + static func cleanupOneOffHostsFile(_ path: String?, containerName: String, autoRemove: Bool) async { + guard let path else { return } + if !autoRemove { + let client = ContainerClient() + guard (try? await client.get(id: containerName)) == nil else { return } + } + try? FileManager.default.removeItem(atPath: path) + } + + static func oneOffContainerName(projectName: String, serviceName: String, suffix: String? = nil) -> String { + let resolvedSuffix = suffix ?? String((0..<6).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! }) + return "\(projectName)-\(serviceName)-run-\(resolvedSuffix)" + } + + static func baseEnvironment(service svc: Service, composeDirectory: String, baseEnv: [String: String]) -> [String: String] { + var combined = baseEnv + if let envFiles = svc.env_file { + for envFile in envFiles { + let additional = loadEnvFile(path: URL(fileURLWithPath: envFile, relativeTo: URL(fileURLWithPath: composeDirectory)).path) + combined.merge(additional) { current, _ in current } + } + } + if let serviceEnv = svc.environment { + combined.merge(serviceEnv) { old, new in + guard !new.contains("${") else { + return old + } + return new + } + } + return combined.mapValues { value in + guard value.contains("${") else { return value } + let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast()) + return combined[variableName] ?? value + } + } + + static func parseEnvOverrides(_ entries: [String], base: [String: String]) -> [String: String] { + Service.parseEnvironmentList(entries).mapValues { resolveVariable($0, with: base) } + } + + static func parseLabelOverrides(_ entries: [String]) -> [String: String] { + var result: [String: String] = [:] + for entry in entries { + if let index = entry.firstIndex(of: "=") { + result[String(entry[.. Bool { + let imageList = try await ClientImage.list() + return imageList.contains { ref in + let stored = ref.description.reference + return stored == imageName + || stored.hasSuffix("/\(imageName)") + || stored.components(separatedBy: "/").last == imageName + } + } + + static func pullImageIfMissing(_ imageName: String, platform: String?, logging: Flags.Logging) async throws { + guard try await !imageExistsLocally(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() + } + + static func reclaimRunName(_ runName: String, rm: Bool) async throws { + let client = ContainerClient() + guard let existing = try? await client.get(id: runName) else { + return + } + if rm { + print("Removing existing one-off container '\(runName)' (--rm).") + try? await client.stop(id: existing.id) + try await client.delete(id: existing.id) + } else { + throw ComposeError.containerNameTaken(runName) + } + } + + static func containerRunning(names: [String]) async -> Bool { + let client = ContainerClient() + for name in names { + if let container = try? await client.get(id: name), container.status == .running { + return true + } + } + return false + } + + static func firstIPv4(names: [String]) async -> String? { + let client = ContainerClient() + for name in names { + if let container = try? await client.get(id: name), + let ip = container.networks.compactMap({ $0.ipv4Address.address.description }).first { + return ip + } + } + return nil + } + + static func dnsDomainRegistered(_ domain: String) async -> Bool { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "dns", "list"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return false } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return false } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return ComposeUp.dnsListContainsDomain(String(data: data, encoding: .utf8) ?? "", domain: domain) + } + + static func ensureNetwork(name: String, external: Bool, logging: Flags.Logging) async throws { + if external { + print("Info: Network '\(name)' is declared as external.") + print("This tool assumes external network '\(name)' already exists and will not attempt to create it.") + return + } + guard (try? await NetworkClient().get(id: name)) == nil else { + return + } + print("Creating network: \(name)") + let networkCreate = try Application.NetworkCreate.parse([name] + logging.passThroughCommands()) + try await networkCreate.run() + } + + static func ensureVolume(name: String, config: Volume, projectName: String) async throws { + let actualVolumeName = composeNamedVolumeName( + source: name, + projectName: projectName, + volumeDefinition: config + ) + if config.external?.isExternal == true { + print("Info: Volume '\(name)' is declared as external.") + print("This tool assumes external volume '\(actualVolumeName)' already exists and will not attempt to create it.") + return + } + if (try? await ClientVolume.inspect(actualVolumeName)) != nil { + return + } + print("Creating volume: \(name) (Actual name: \(actualVolumeName))") + _ = try await ClientVolume.create( + name: actualVolumeName, + driver: config.driver ?? "local", + driverOpts: config.driver_opts ?? [:], + labels: config.labels ?? [:] + ) + } +} diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7f..1c479169 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -835,15 +835,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { 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") @@ -1070,21 +1061,16 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--read-only") } - // Add resource limits. - // `mem_limit` is the top-level shorthand; `deploy.resources.limits.memory` is - // the structured form. Both map to `container run --memory`. `mem_limit` takes - // precedence when both are set, matching Docker Compose CLI behaviour. - if let cpus = service.deploy?.resources?.limits?.cpus { - runCommandArgs.append(contentsOf: ["--cpus", cpus]) - } - let effectiveMemoryLimit = service.mem_limit ?? service.deploy?.resources?.limits?.memory - if let memory = effectiveMemoryLimit { - let resolved = resolveVariable(memory, with: environmentVariables) - let (memoryArg, didClamp) = Self.clampMemoryLimit(resolved) - if didClamp { - print("Note: Service '\(serviceName)' mem_limit '\(resolved)' is below Apple Container's 200 MiB minimum; clamping to \(memoryArg).") - } - runCommandArgs.append(contentsOf: ["--memory", memoryArg]) + // Add resource limits: service-level shorthands and `deploy.resources` + // limits fold into the same `container run` flags. See ResourceArguments. + let resources = ResourceArguments.runArgs( + for: service, + serviceName: serviceName, + environmentVariables: environmentVariables + ) + runCommandArgs.append(contentsOf: resources.args) + for note in resources.notes { + print(note) } // Handle service-level configs (note: still only parsing/logging, not attaching) @@ -1354,7 +1340,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { commands.append(contentsOf: ["--tag", imageToRun]) // Add CPU & Memory - let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2 + let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? service.cpus ?? "2") ?? 2 let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB" commands.append(contentsOf: ["--cpus", "\(cpuCount)"]) commands.append(contentsOf: ["--memory", memoryLimit]) @@ -1393,6 +1379,45 @@ extension ComposeUp { static func extraHostsFilePath(projectName: String, serviceName: String) -> String { NSTemporaryDirectory() + "container-compose-\(projectName)-\(serviceName)-hosts" } + + /// Prefix shared by the generated /etc/hosts bind-mount sources for + /// one-off `run` containers of a project. `ComposeDown` sweeps files with + /// this prefix to collect stale ones. + static func runExtraHostsFilenamePrefix(projectName: String) -> String { + "container-compose-\(projectName)-run-" + } + + /// Path for the generated /etc/hosts bind-mount source of a one-off `run` + /// container. Keyed by the resolved container name (unique per run unless + /// the user reuses `--name`), so concurrent one-off runs of the same + /// service never overwrite each other's hosts file. The name is sanitized + /// because it is interpolated into a path before Apple's own container + /// name validation runs. + static func runExtraHostsFilePath(projectName: String, containerName: String) -> String { + NSTemporaryDirectory() + + runExtraHostsFilenamePrefix(projectName: projectName) + + sanitizeFilenameComponent(containerName) + + "-hosts" + } + + /// Extracts the container name from a filename created by + /// `runExtraHostsFilePath`. The name may itself contain `-`, so the + /// prefix/suffix are stripped rather than splitting on a separator. + /// Returns `nil` for unrelated files or an empty name. + static func runContainerName(fromHostsFilename filename: String, projectName: String) -> String? { + let prefix = runExtraHostsFilenamePrefix(projectName: projectName) + let suffix = "-hosts" + guard filename.hasPrefix(prefix), filename.hasSuffix(suffix) else { return nil } + let name = String(filename.dropFirst(prefix.count).dropLast(suffix.count)) + return name.isEmpty ? nil : name + } + + /// Restricts a value to characters that are safe in a filename, so an + /// unusual `--name` cannot escape the temporary directory. + static func sanitizeFilenameComponent(_ value: String) -> String { + let allowed = Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-") + return String(value.map { allowed.contains($0) ? $0 : "_" }) + } } // MARK: Host gateway resolution diff --git a/Sources/Container-Compose/Errors.swift b/Sources/Container-Compose/Errors.swift index 6e101162..fc058ed8 100644 --- a/Sources/Container-Compose/Errors.swift +++ b/Sources/Container-Compose/Errors.swift @@ -46,6 +46,7 @@ public enum ComposeError: Error, LocalizedError { case healthcheckUnavailable(String) case healthcheckFailed(String) case noSuchService(String) + case containerNameTaken(String) public var errorDescription: String? { switch self { @@ -67,6 +68,8 @@ public enum ComposeError: Error, LocalizedError { return "Service '\(service)' failed its healthcheck." case .noSuchService(let name): return "no such service: \(name)" + case .containerNameTaken(let name): + return "container with id \(name) already exists. Run 'container rm \(name)' first, or pass --rm to replace it." } } } diff --git a/Sources/Container-Compose/ResourceArguments.swift b/Sources/Container-Compose/ResourceArguments.swift new file mode 100644 index 00000000..456aba92 --- /dev/null +++ b/Sources/Container-Compose/ResourceArguments.swift @@ -0,0 +1,179 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Translates a Compose service's resource configuration into `container run` +/// flags shared by `up`, `run`, and `build`. +/// +/// Compose expresses resources two ways — the service-level shorthands +/// (`cpus`, `mem_limit`, `mem_reservation`, `shm_size`, `tmpfs`, `ulimits`) and +/// the `deploy.resources` block. Both fold into Apple Container's limited flag +/// set; anything Apple Container cannot express (soft limits, device/GPU +/// reservations, replicas) is reported through `notes` instead of silently +/// dropped. +enum ResourceArguments { + struct Result { + var args: [String] = [] + var notes: [String] = [] + } + + static func runArgs( + for service: Service, + serviceName: String, + environmentVariables: [String: String] + ) -> Result { + var result = Result() + + func resolve(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + return resolveVariable(value, with: environmentVariables) + } + + // `deploy` is parsed but only its resource limits are usable for a + // standalone container; say so once per service instead of listing + // each unsupported field. + if let deploy = service.deploy { + let hasOrchestrationFields = deploy.mode != nil + || deploy.replicas != nil + || deploy.replicasExpression != nil + || deploy.restart_policy != nil + if hasOrchestrationFields { + result.notes.append( + "Note: Service '\(serviceName)' defines 'deploy' orchestration fields (mode/replicas/restart_policy); " + + "this tool runs a single container and does not apply them." + ) + } + } + + // CPU limit: the official `deploy.resources.limits.cpus` wins over the + // legacy service-level `cpus` shorthand, matching the Compose spec's + // preference for the deploy form. + let deployCpus = service.deploy?.resources?.limits?.cpus + if deployCpus != nil, service.cpus != nil { + result.notes.append( + "Note: Service '\(serviceName)' sets both 'cpus' and 'deploy.resources.limits.cpus'; " + + "using the official deploy limit." + ) + } + // Apple Container's `--cpus` only takes whole CPUs, while Compose allows + // fractional values (e.g. "1.5"), so round fractional requests up and say so. + if let cpus = resolve(deployCpus ?? service.cpus) { + if let value = Double(cpus) { + if value.truncatingRemainder(dividingBy: 1) == 0 { + result.args.append(contentsOf: ["--cpus", "\(Int(value))"]) + } else { + let rounded = Int(value.rounded(.up)) + result.args.append(contentsOf: ["--cpus", "\(rounded)"]) + result.notes.append( + "Note: Service '\(serviceName)' requests \(cpus) CPUs; Apple Container only supports " + + "whole CPUs, rounding up to \(rounded)." + ) + } + } else { + result.args.append(contentsOf: ["--cpus", cpus]) + } + } + + // Apple Container has no soft CPU reservation concept. + if let reservedCpus = service.deploy?.resources?.reservations?.cpus { + result.notes.append( + "Note: Service '\(serviceName)' sets deploy.resources.reservations.cpus ('\(reservedCpus)'); " + + "Apple Container has no CPU reservation support; ignoring." + ) + } + + // Memory: a hard limit wins; a lone reservation is approximated as the + // hard `--memory` limit since Apple Container has no soft limit. The + // official `deploy.resources` values win over the legacy shorthands. + let deployLimitMemory = service.deploy?.resources?.limits?.memory + if deployLimitMemory != nil, service.mem_limit != nil { + result.notes.append( + "Note: Service '\(serviceName)' sets both 'mem_limit' and 'deploy.resources.limits.memory'; " + + "using the official deploy limit." + ) + } + let limitMemory = resolve(deployLimitMemory ?? service.mem_limit) + + let deployReservedMemory = service.deploy?.resources?.reservations?.memory + if deployReservedMemory != nil, service.mem_reservation != nil { + result.notes.append( + "Note: Service '\(serviceName)' sets both 'mem_reservation' and " + + "'deploy.resources.reservations.memory'; using the official deploy reservation." + ) + } + let reservedMemory = resolve(deployReservedMemory ?? service.mem_reservation) + + let effectiveMemory = limitMemory ?? reservedMemory + if let effectiveMemory { + let (memoryArg, didClamp) = ComposeUp.clampMemoryLimit(effectiveMemory) + if didClamp { + result.notes.append( + "Note: Service '\(serviceName)' memory '\(effectiveMemory)' is below Apple Container's " + + "200 MiB minimum; clamping to \(memoryArg)." + ) + } + if limitMemory == nil, reservedMemory != nil { + result.notes.append( + "Note: Service '\(serviceName)' only sets a memory reservation; Apple Container has no soft " + + "memory limit, using it as the hard --memory limit." + ) + } + result.args.append(contentsOf: ["--memory", memoryArg]) + } + if limitMemory != nil, reservedMemory != nil { + result.notes.append( + "Note: Service '\(serviceName)' sets a memory reservation alongside a hard limit; " + + "Apple Container has no soft memory limit; ignoring the reservation." + ) + } + + // Apple Container has no device/GPU passthrough flags. + if let reservedDevices = service.deploy?.resources?.reservations?.devices, !reservedDevices.isEmpty { + result.notes.append( + "Note: Service '\(serviceName)' reserves devices; Apple Container does not support " + + "device/GPU reservations; ignoring." + ) + } + if let devices = service.devices, !devices.isEmpty { + result.notes.append( + "Note: Service '\(serviceName)' maps devices; Apple Container does not support device " + + "mappings; ignoring." + ) + } + + if let shmSize = resolve(service.shm_size) { + result.args.append(contentsOf: ["--shm-size", shmSize]) + } + + for entry in service.tmpfs ?? [] { + result.args.append(contentsOf: ["--tmpfs", resolve(entry) ?? entry]) + } + + for name in (service.ulimits ?? [:]).keys.sorted() { + guard let ulimit = service.ulimits?[name] else { continue } + guard let soft = resolve(ulimit.soft ?? ulimit.hard) else { continue } + let hard = resolve(ulimit.hard ?? ulimit.soft) + var formatted = "\(name)=\(soft)" + if let hard, hard != soft { + formatted += ":\(hard)" + } + result.args.append(contentsOf: ["--ulimit", formatted]) + } + + return result + } +} diff --git a/Tests/Container-Compose-StaticTests/ComposeRunTests.swift b/Tests/Container-Compose-StaticTests/ComposeRunTests.swift new file mode 100644 index 00000000..f432bd5d --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ComposeRunTests.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +import Foundation +@testable import ContainerComposeCore + +@Suite("Compose run") +struct ComposeRunTests { + @Test("Run registered under Main") + func runRegisteredUnderMain() throws { + let cmd = try Main.parseAsRoot(["run", "web"]) as! ComposeRun + #expect(cmd.service == "web") + } + + @Test("Trailing command overrides service command") + func trailingCommandOverrides() throws { + let cmd = try ComposeRun.parse(["web", "python", "app.py"]) + #expect(cmd.service == "web") + #expect(cmd.command == ["python", "app.py"]) + } + + @Test("Flags parse") + func flagsParse() throws { + let cmd = try ComposeRun.parse(["-d", "--rm", "--no-deps", "-P", "--name", "once", "web", "true"]) + #expect(cmd.detach && cmd.rm && cmd.noDeps && cmd.servicePorts) + #expect(cmd.name == "once") + } + + @Test("Publish/env/volume/label/entrypoint flags parse") + func overrideFlagsParse() throws { + let cmd = try ComposeRun.parse([ + "-p", "8080:80", "-e", "KEY=val", "-v", "data:/data", + "-l", "a=b", "--entrypoint", "/bin/sh", "web", + ]) + #expect(cmd.publish == ["8080:80"]) + #expect(cmd.volume == ["data:/data"]) + #expect(cmd.label == ["a=b"]) + #expect(cmd.entrypoint == "/bin/sh") + #expect(cmd.projectOptions.process.env == ["KEY=val"]) + } + + @Test("One-off name is unique and prefixed") + func oneOffNameUniquePrefixed() { + let a = ComposeRun.oneOffContainerName(projectName: "proj", serviceName: "web") + let b = ComposeRun.oneOffContainerName(projectName: "proj", serviceName: "web") + #expect(a.hasPrefix("proj-web-run-")) + #expect(a != b) + } + + @Test("Label overrides parse") + func labelOverridesParse() { + #expect(ComposeRun.parseLabelOverrides(["a=b", "solo"]) == ["a": "b", "solo": ""]) + } + + @Test("Env overrides resolve against base") + func envOverridesResolve() { + let out = ComposeRun.parseEnvOverrides(["HOST_PORT=${PORT}"], base: ["PORT": "8080"]) + #expect(out == ["HOST_PORT": "8080"]) + } + + @Test("baseArgs forwards shared options to delegated commands") + func baseArgsForwardsSharedOptions() throws { + let cmd = try ComposeRun.parse([ + "-f", "x.yml", "--profile", "dev", "--workdir", "/tmp", "-e", "A=1", "--debug", "web", + ]) + let args = cmd.baseArgs() + #expect(args.contains("-f") && args.contains("x.yml")) + #expect(args.contains("--profile") && args.contains("dev")) + #expect(args.contains("--workdir") && args.contains("/tmp")) + #expect(args.contains("-e") && args.contains("A=1")) + #expect(args.contains("--debug")) + } + + @Test("One-off hosts path is unique per run and sanitized") + func oneOffHostsPathUniqueAndSanitized() { + let first = ComposeUp.runExtraHostsFilePath(projectName: "proj", containerName: "proj-web-run-abc123") + let second = ComposeUp.runExtraHostsFilePath(projectName: "proj", containerName: "proj-web-run-def456") + #expect(first != second) + #expect(first.hasSuffix("proj-web-run-abc123-hosts")) + + let sanitized = ComposeUp.runExtraHostsFilePath(projectName: "proj", containerName: "../evil/name") + #expect(!sanitized.contains("/evil")) + #expect(sanitized.hasSuffix(".._evil_name-hosts")) + } + + @Test("Hosts filename parses back to the container name") + func hostsFilenameParsesContainerName() { + let path = ComposeUp.runExtraHostsFilePath(projectName: "proj", containerName: "my-app.web-run-abc") + let filename = URL(fileURLWithPath: path).lastPathComponent + #expect(ComposeUp.runContainerName(fromHostsFilename: filename, projectName: "proj") == "my-app.web-run-abc") + } + + @Test("Hosts filename parsing rejects unrelated and empty names") + func hostsFilenameParsingRejectsUnrelated() { + #expect(ComposeUp.runContainerName(fromHostsFilename: "other-file", projectName: "proj") == nil) + #expect(ComposeUp.runContainerName(fromHostsFilename: "container-compose-other-run-x-hosts", projectName: "proj") == nil) + #expect(ComposeUp.runContainerName(fromHostsFilename: "container-compose-proj-run--hosts", projectName: "proj") == nil) + } +} diff --git a/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift index 4ed7d048..2cd68980 100644 --- a/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift +++ b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift @@ -76,14 +76,14 @@ struct MemLimitExtraHostsTests { #expect(svc.mem_limit == nil) } - @Test("mem_limit takes precedence over deploy.resources.limits.memory in run args") + @Test("deploy.resources.limits.memory takes precedence over mem_limit in run args") func memLimitPrecedence() throws { - // Verify effective memory limit selection logic matches Docker Compose semantics: - // mem_limit wins when both are present. + // The official deploy form wins when both are present; the legacy + // mem_limit shorthand only applies on its own. let memLimit: String? = "512m" let deployMemory: String? = "1g" - let effective = memLimit ?? deployMemory - #expect(effective == "512m") + let effective = deployMemory ?? memLimit + #expect(effective == "1g") } // MARK: - extra_hosts diff --git a/Tests/Container-Compose-StaticTests/ResourceOptionsTests.swift b/Tests/Container-Compose-StaticTests/ResourceOptionsTests.swift new file mode 100644 index 00000000..f386aea0 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/ResourceOptionsTests.swift @@ -0,0 +1,238 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing +import Foundation +@testable import Yams +@testable import ContainerComposeCore + +@Suite("Resource options") +struct ResourceOptionsTests { + private func service(from yaml: String) throws -> Service { + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + return try #require(compose.services["app"] ?? nil) + } + + @Test("Parse service-level resource shorthands") + func parseServiceLevelShorthands() throws { + let service = try service(from: """ + services: + app: + image: alpine + cpus: 1.5 + mem_limit: 512m + mem_reservation: 256m + shm_size: 1g + tmpfs: /run + ulimits: + nofile: 65535 + nproc: + soft: 1024 + hard: 2048 + devices: + - /dev/kvm:/dev/kvm + """) + + #expect(service.cpus == "1.5") + #expect(service.mem_limit == "512m") + #expect(service.mem_reservation == "256m") + #expect(service.shm_size == "1g") + #expect(service.tmpfs == ["/run"]) + #expect(service.ulimits?["nofile"]?.soft == "65535") + #expect(service.ulimits?["nofile"]?.hard == "65535") + #expect(service.ulimits?["nproc"]?.soft == "1024") + #expect(service.ulimits?["nproc"]?.hard == "2048") + #expect(service.devices == ["/dev/kvm:/dev/kvm"]) + } + + @Test("Parse tmpfs list form and numeric cpus") + func parseTmpfsListAndNumericCpus() throws { + let service = try service(from: """ + services: + app: + image: alpine + cpus: 2 + tmpfs: + - /run + - /tmp + """) + + #expect(service.cpus == "2") + #expect(service.tmpfs == ["/run", "/tmp"]) + } + + @Test("Resource args map limits, shm, tmpfs, and ulimits") + func resourceArgsMap() { + let service = Service( + image: "alpine", + mem_limit: "1g", + cpus: "2", + shm_size: "512m", + tmpfs: ["/run"], + ulimits: ["nofile": Ulimit(soft: "1024", hard: "2048")] + ) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == [ + "--cpus", "2", + "--memory", "1g", + "--shm-size", "512m", + "--tmpfs", "/run", + "--ulimit", "nofile=1024:2048", + ]) + } + + @Test("Soft-only ulimit emits a single value") + func softOnlyUlimit() { + let service = Service(image: "alpine", ulimits: ["nproc": Ulimit(soft: "512")]) + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--ulimit", "nproc=512"]) + } + + @Test("mem_limit is clamped to Apple Container's 200 MiB minimum") + func memoryClamped() { + let service = Service(image: "alpine", mem_limit: "128m") + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--memory", "200m"]) + #expect(result.notes.contains { $0.contains("clamping") }) + } + + @Test("Fractional cpus round up for Apple Container") + func fractionalCpusRoundUp() { + let service = Service(image: "alpine", cpus: "1.5") + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--cpus", "2"]) + #expect(result.notes.contains { $0.contains("rounding up") }) + } + + @Test("Deploy limits win over service-level cpus shorthand") + func cpusPrecedence() throws { + let service = try service(from: """ + services: + app: + image: alpine + cpus: "8" + deploy: + resources: + limits: + cpus: "2" + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--cpus", "2"]) + #expect(result.notes.contains { $0.contains("using the official deploy limit") }) + } + + @Test("Deploy limits apply on their own") + func deployLimitsOnly() throws { + let service = try service(from: """ + services: + app: + image: alpine + deploy: + resources: + limits: + cpus: "3" + memory: 1g + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--cpus", "3", "--memory", "1g"]) + } + + @Test("Deploy memory limit wins over mem_limit shorthand") + func memoryPrecedence() throws { + let service = try service(from: """ + services: + app: + image: alpine + mem_limit: 512m + deploy: + resources: + limits: + memory: 1g + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--memory", "1g"]) + #expect(result.notes.contains { $0.contains("using the official deploy limit") }) + } + + @Test("Memory reservation becomes the hard limit when no limit is set") + func reservationAsHardLimit() throws { + let service = try service(from: """ + services: + app: + image: alpine + mem_reservation: 256m + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--memory", "256m"]) + #expect(result.notes.contains { $0.contains("soft memory limit") }) + } + + @Test("Reservation is ignored when a hard limit exists") + func reservationIgnoredWithHardLimit() throws { + let service = try service(from: """ + services: + app: + image: alpine + mem_limit: 1g + mem_reservation: 256m + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.args == ["--memory", "1g"]) + #expect(result.notes.contains { $0.contains("ignoring the reservation") }) + } + + @Test("Unsupported reservations and devices produce notes") + func unsupportedResourcesProduceNotes() throws { + let service = try service(from: """ + services: + app: + image: alpine + devices: + - /dev/kvm:/dev/kvm + deploy: + mode: replicated + replicas: 3 + resources: + reservations: + cpus: "0.5" + devices: + - capabilities: [gpu] + """) + + let result = ResourceArguments.runArgs(for: service, serviceName: "app", environmentVariables: [:]) + #expect(result.notes.contains { $0.contains("orchestration fields") }) + #expect(result.notes.contains { $0.contains("reservations.cpus") }) + #expect(result.notes.contains { $0.contains("device/GPU reservations") }) + #expect(result.notes.contains { $0.contains("device mappings") }) + } + + @Test("Environment variables resolve in resource values") + func environmentVariablesResolve() { + let service = Service(image: "alpine", cpus: "${CPUS}", shm_size: "${SHM}") + let result = ResourceArguments.runArgs( + for: service, + serviceName: "app", + environmentVariables: ["CPUS": "4", "SHM": "256m"] + ) + #expect(result.args == ["--cpus", "4", "--shm-size", "256m"]) + } +}