diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index fd60b7c..06a91a6 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -104,13 +104,23 @@ public struct Service: Codable, Hashable { /// Allocate a pseudo-TTY (-t flag for `container run`) 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. + public let mem_limit: 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. + public let extra_hosts: [String]? + /// Other services that depend on this service public var dependedBy: [String] = [] - + // Defines custom coding keys to map YAML keys to Swift properties 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 + container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform, + mem_limit, extra_hosts } /// Public memberwise initializer for testing @@ -140,6 +150,8 @@ public struct Service: Codable, Hashable { secrets: [ServiceSecret]? = nil, stdin_open: Bool? = nil, tty: Bool? = nil, + mem_limit: String? = nil, + extra_hosts: [String]? = nil, dependedBy: [String] = [] ) { self.image = image @@ -167,6 +179,8 @@ public struct Service: Codable, Hashable { self.secrets = secrets self.stdin_open = stdin_open self.tty = tty + self.mem_limit = mem_limit + self.extra_hosts = extra_hosts self.dependedBy = dependedBy } @@ -255,6 +269,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)" + } else { + mem_limit = nil + } + + // `extra_hosts` accepts two forms per the Compose spec: + // extra_hosts: extra_hosts: + // - "hostname:IP" or hostname: IP + // - "other:host-gateway" + // The list form is most common; the map form is normalised to list form here. + if let list = try? container.decodeIfPresent([String].self, forKey: .extra_hosts) { + extra_hosts = list + } else if let map = try? container.decodeIfPresent([String: String].self, forKey: .extra_hosts) { + extra_hosts = map.map { "\($0.key):\($0.value)" } + } else { + extra_hosts = nil + } } /// Translates the list-form of `environment:` into the same `[String: String]` diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 334a89f..b41a4e9 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -155,6 +155,10 @@ public struct ComposeDown: AsyncParsableCommand { if !stoppedAny { print("Warning: No container found for service '\(serviceName)' (tried: \(candidates.joined(separator: ", "))).") } + + // Best-effort: the extra_hosts bind-mount source (if any) is only safe + // to remove once the container that had it mounted is stopped. + try? fileManager.removeItem(atPath: ComposeUp.extraHostsFilePath(projectName: projectName, serviceName: serviceName)) } } } diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index ea4cac0..0abfa5c 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -730,6 +730,66 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(resolvedHostname) } + // Add extra_hosts entries. `container run` (verified against container CLI + // 1.0.0) has no --add-host flag at all — passing one fails immediately with + // "Error: Unknown option '--add-host'" — so entries are written to a hosts + // file and bind-mounted over /etc/hosts instead. + // + // The special token `host-gateway` is resolved via `container network + // inspect`, which reports the vmnet bridge gateway the container actually + // uses to reach the host. `route -n get default` (the previous approach) + // instead returns the Mac's LAN default-route gateway, which is a different + // address whenever the machine's default route isn't the vmnet bridge (e.g. + // any normal Wi-Fi/Ethernet setup) — so containers would gain an entry + // pointing at the router, not the host. + if let extraHosts = service.extra_hosts, !extraHosts.isEmpty { + print( + "Note: service '\(serviceName)' sets extra_hosts. Since 'container run' has no --add-host " + + "flag, /etc/hosts is generated from scratch and bind-mounted in, which replaces the " + + "daemon's own generated file wholesale (Docker's --add-host appends instead). The " + + "container's own hostname is re-added below so self-resolution keeps working, but any " + + "other daemon-managed entries are not preserved." + ) + + // Resolve variables first: needsGateway must inspect the resolved value, + // otherwise an entry fully wrapped in a variable (e.g. "${HOST_ENTRY}" + // expanding to "foo:host-gateway") is missed and hostGatewayIP is left + // empty, silently producing "--add-host foo:" (now "foo:" in /etc/hosts). + let resolvedEntries = extraHosts.map { resolveVariable($0, with: environmentVariables) } + let needsGateway = resolvedEntries.contains { $0.hasSuffix(":host-gateway") } + let resolvedNetworkName = service.networks?.first.map { resolveVariable($0, with: environmentVariables) } ?? "default" + let hostGatewayIP = needsGateway ? Self.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 } + let hostname = parts[0] + let ip = parts[1] == "host-gateway" ? hostGatewayIP : parts[1] + hostsFileLines.append("\(ip) \(hostname)") + seenHostnames.insert(hostname) + } + + // Re-add the container's own hostname → 127.0.0.1, the entry Apple's + // container daemon would normally generate itself, so anything inside + // the container that resolves its own hostname still works. Skipped if + // extra_hosts already defines that name explicitly, so an intentional + // user override wins. + let ownHostname = service.hostname.map { resolveVariable($0, with: environmentVariables) } ?? containerName + if !seenHostnames.contains(ownHostname) { + hostsFileLines.append("127.0.0.1 \(ownHostname)") + } + + let hostsFilePath = Self.extraHostsFilePath(projectName: projectName, serviceName: serviceName) + do { + try (hostsFileLines.joined(separator: "\n") + "\n").write(toFile: hostsFilePath, atomically: true, encoding: .utf8) + runCommandArgs.append(contentsOf: ["-v", "\(hostsFilePath):/etc/hosts"]) + } catch { + print("Warning: could not write hosts file for service '\(serviceName)' extra_hosts at \(hostsFilePath): \(error.localizedDescription)") + } + } + // Add working directory if let workingDir = service.working_dir { let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) @@ -747,11 +807,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append("--read-only") } - // Add resource limits + // 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]) } - if let memory = service.deploy?.resources?.limits?.memory { + let effectiveMemoryLimit = service.mem_limit ?? service.deploy?.resources?.limits?.memory + if let memory = effectiveMemoryLimit { runCommandArgs.append(contentsOf: ["--memory", memory]) } @@ -942,6 +1006,89 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } +// MARK: extra_hosts file management +extension ComposeUp { + /// Deterministic path for the generated /etc/hosts bind-mount source for a + /// given service (see the extra_hosts handling in `configService`). Reused + /// (overwritten) across repeated `up` runs of the same service rather than + /// accumulating a new file each time. `ComposeDown` removes it once the + /// container it was mounted into is actually stopped — not `up`, since the + /// file may still be bind-mounted into a running container. + static func extraHostsFilePath(projectName: String, serviceName: String) -> String { + NSTemporaryDirectory() + "container-compose-\(projectName)-\(serviceName)-hosts" + } +} + +// MARK: Host gateway resolution +extension ComposeUp { + /// Resolves the `host-gateway` token to the IP containers actually use to reach + /// the host, for translating `extra_hosts: ["hostname:host-gateway"]` into a + /// concrete `/etc/hosts` entry (see the extra_hosts handling in `configService`). + /// + /// Queries `container network inspect ` for `status.ipv4Gateway` — + /// the vmnet bridge gateway Apple's `container` runtime routes container→host + /// traffic through. This is deliberately *not* `route -n get default`: that + /// reports the Mac's LAN default-route gateway (the router), which is a + /// different, unreachable-from-the-container address on any machine whose + /// default route isn't the vmnet bridge itself — i.e. almost always. + /// + /// Falls back to parsing `route -n get default` (the previous, unreliable + /// approach) only if the network can't be inspected, with a warning — better + /// than nothing, but callers should treat it as a guess. + static func resolveHostGatewayIP(networkName: String = "default") -> String { + if let gateway = inspectNetworkGateway(networkName) { + return gateway + } + + print( + "Warning: could not determine network '\(networkName)' gateway via 'container network inspect'; " + + "falling back to the host's LAN default-route gateway, which may not be reachable from the container." + ) + + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["route", "-n", "get", "default"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return "host-gateway" } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return "host-gateway" } + let output = String(data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + for line in output.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("gateway:") { + return trimmed.dropFirst("gateway:".count).trimmingCharacters(in: .whitespaces) + } + } + return "host-gateway" + } + + /// Runs `container network inspect ` and extracts `status.ipv4Gateway`. + /// Returns `nil` on any failure (missing binary, non-zero exit, unexpected JSON shape). + private static func inspectNetworkGateway(_ networkName: String) -> String? { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "network", "inspect", networkName] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard + let networks = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let status = networks.first?["status"] as? [String: Any], + let gateway = status["ipv4Gateway"] as? String, + !gateway.isEmpty + else { + return nil + } + return gateway + } +} + // MARK: CommandLine Functions extension ComposeUp { diff --git a/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift new file mode 100644 index 0000000..b09d812 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// 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("mem_limit and extra_hosts parsing") +struct MemLimitExtraHostsTests { + + private func decodeService(_ serviceYaml: String) throws -> Service { + let yaml = """ + services: + svc: + \(serviceYaml.split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" }.joined(separator: "\n")) + """ + let compose = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + guard let service = compose.services["svc"].flatMap({ $0 }) else { + Issue.record("service 'svc' missing or nil") + throw TestError.missingService + } + return service + } + + enum TestError: Error { case missingService } + + // MARK: - mem_limit + + @Test("mem_limit string form parses") + func memLimitString() throws { + let svc = try decodeService(""" + image: alpine + mem_limit: 512m + """) + #expect(svc.mem_limit == "512m") + } + + @Test("mem_limit gigabyte value parses") + func memLimitGigabyte() throws { + let svc = try decodeService(""" + image: alpine + mem_limit: 2g + """) + #expect(svc.mem_limit == "2g") + } + + @Test("mem_limit integer bytes form parses to string") + func memLimitInteger() throws { + let svc = try decodeService(""" + image: alpine + mem_limit: 536870912 + """) + #expect(svc.mem_limit == "536870912") + } + + @Test("mem_limit absent when not set") + func memLimitAbsent() throws { + let svc = try decodeService(""" + image: alpine + """) + #expect(svc.mem_limit == nil) + } + + @Test("mem_limit takes precedence over deploy.resources.limits.memory in run args") + func memLimitPrecedence() throws { + // Verify effective memory limit selection logic matches Docker Compose semantics: + // mem_limit wins when both are present. + let memLimit: String? = "512m" + let deployMemory: String? = "1g" + let effective = memLimit ?? deployMemory + #expect(effective == "512m") + } + + // MARK: - extra_hosts + + @Test("extra_hosts list form parses") + func extraHostsList() throws { + let svc = try decodeService(""" + image: alpine + extra_hosts: + - "logto.localhost:192.168.64.1" + """) + #expect(svc.extra_hosts == ["logto.localhost:192.168.64.1"]) + } + + @Test("extra_hosts list form with host-gateway token parses") + func extraHostsHostGateway() throws { + let svc = try decodeService(""" + image: alpine + extra_hosts: + - "logto.localhost:host-gateway" + """) + #expect(svc.extra_hosts == ["logto.localhost:host-gateway"]) + } + + @Test("extra_hosts map form normalised to list") + func extraHostsMapForm() throws { + let svc = try decodeService(""" + image: alpine + extra_hosts: + logto.localhost: "192.168.64.1" + """) + // Map order is not guaranteed; just check the entry is present. + #expect(svc.extra_hosts?.contains("logto.localhost:192.168.64.1") == true) + #expect(svc.extra_hosts?.count == 1) + } + + @Test("extra_hosts absent when not set") + func extraHostsAbsent() throws { + let svc = try decodeService(""" + image: alpine + """) + #expect(svc.extra_hosts == nil) + } + + @Test("extra_hosts multiple entries all parsed") + func extraHostsMultiple() throws { + let svc = try decodeService(""" + image: alpine + extra_hosts: + - "host1:10.0.0.1" + - "host2:host-gateway" + """) + #expect(svc.extra_hosts?.count == 2) + #expect(svc.extra_hosts?.contains("host1:10.0.0.1") == true) + #expect(svc.extra_hosts?.contains("host2:host-gateway") == true) + } + + // MARK: - host-gateway resolution + + @Test("resolveHostGatewayIP returns a non-empty string") + func resolveHostGatewayReturnsString() { + // The resolved value is machine-specific; we just verify it runs and returns + // something (either a real IP or the "host-gateway" fallback). + let result = ComposeUp.resolveHostGatewayIP() + #expect(!result.isEmpty) + } +}