From 95ed5cba60ef7689593019c03f69380af062ed54 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Tue, 30 Jun 2026 22:29:20 +0100 Subject: [PATCH 1/3] fix(service): add mem_limit and extra_hosts support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements two commonly-used Compose keys that container-compose v1.0.0 does not support: **mem_limit** — top-level memory limit shorthand (e.g. `mem_limit: 2g`). Decoded as a String; also accepts bare integer bytes (YAML `mem_limit: 536870912`). Mapped to `--memory` in ComposeUp, taking precedence over `deploy.resources.limits.memory` when both are set. **extra_hosts** — additional /etc/hosts entries. Accepts both Compose spec forms: list (`["hostname:IP"]`) and map (`{hostname: IP}`). The special token `host-gateway` is resolved to the host machine's default gateway IP via `route -n get default`; resolution runs lazily (only when at least one entry actually uses the token). Passed to `container run` as `--add-host hostname:IP` (colon separator, per the OCI standard). Tests cover: string mem_limit, gigabyte mem_limit, integer bytes mem_limit, absent mem_limit, list extra_hosts, host-gateway token, map form normalisation, absent extra_hosts, multiple entries, and resolveHostGatewayIP return type. Test helper updated to use flatMap({ $0 }) to correctly unwrap Service?? from the services dictionary. --- .../Codable Structs/Service.swift | 38 ++++- .../Commands/ComposeUp.swift | 53 +++++- .../MemLimitExtraHostsTests.swift | 153 ++++++++++++++++++ 3 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift 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/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index ea4cac0..bde5126 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -730,6 +730,22 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(resolvedHostname) } + // Add extra_hosts entries via --add-host. + // The special token `host-gateway` is resolved to the host machine's gateway + // IP (the IP containers use to reach the host) via `route -n get default`. + if let extraHosts = service.extra_hosts, !extraHosts.isEmpty { + let needsGateway = extraHosts.contains { $0.hasSuffix(":host-gateway") } + let hostGatewayIP = needsGateway ? Self.resolveHostGatewayIP() : "" + for entry in extraHosts { + let resolved = resolveVariable(entry, with: environmentVariables) + 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] + runCommandArgs.append(contentsOf: ["--add-host", "\(hostname):\(ip)"]) + } + } + // Add working directory if let workingDir = service.working_dir { let resolvedWorkingDir = resolveVariable(workingDir, with: environmentVariables) @@ -747,11 +763,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 +962,35 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } +// MARK: Host gateway resolution +extension ComposeUp { + /// Resolves the `host-gateway` token to the host machine's default gateway IP. + /// Used to translate `extra_hosts: ["hostname:host-gateway"]` into a concrete + /// `--add-host` argument that Apple's `container run` accepts. + /// + /// Runs `route -n get default` and parses the `gateway:` line. Falls back to + /// `"host-gateway"` (passed through unmodified) if resolution fails, which will + /// cause `container run` to emit a useful error rather than silently misbehaving. + static func resolveHostGatewayIP() -> String { + 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() + 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" + } +} + // 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) + } +} From 5193a0305dad427386c4e328753ac16453954904 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 1 Jul 2026 11:47:04 +0100 Subject: [PATCH 2/3] fix(service): resolve host-gateway via container network inspect, not route -n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit route -n get default reports the Mac's LAN default-route gateway, which is not the same address as the vmnet bridge gateway containers actually use to reach the host — verified: route -n get default returns 192.168.68.1 (the router) while container network inspect default reports 192.168.64.1 (the real gateway) on the same machine. Query the network directly instead, with route -n get default kept only as a last-resort fallback. Also fixes needsGateway checking extra_hosts entries before resolveVariable runs, which missed entries fully wrapped in a variable, and replaces --add-host (container run has no such flag — confirmed it fails with "Unknown option '--add-host'") with a hosts file bind-mounted over /etc/hosts. --- .../Commands/ComposeUp.swift | 94 ++++++++++++++++--- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index bde5126..672dcaf 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -730,19 +730,42 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(resolvedHostname) } - // Add extra_hosts entries via --add-host. - // The special token `host-gateway` is resolved to the host machine's gateway - // IP (the IP containers use to reach the host) via `route -n get default`. + // 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 { - let needsGateway = extraHosts.contains { $0.hasSuffix(":host-gateway") } - let hostGatewayIP = needsGateway ? Self.resolveHostGatewayIP() : "" - for entry in extraHosts { - let resolved = resolveVariable(entry, with: environmentVariables) + // 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"] + 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] - runCommandArgs.append(contentsOf: ["--add-host", "\(hostname):\(ip)"]) + hostsFileLines.append("\(ip) \(hostname)") + } + let hostsFilePath = NSTemporaryDirectory() + "container-compose-\(projectName)-\(serviceName)-hosts" + 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)") } } @@ -964,14 +987,30 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // MARK: Host gateway resolution extension ComposeUp { - /// Resolves the `host-gateway` token to the host machine's default gateway IP. - /// Used to translate `extra_hosts: ["hostname:host-gateway"]` into a concrete - /// `--add-host` argument that Apple's `container run` accepts. + /// 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. /// - /// Runs `route -n get default` and parses the `gateway:` line. Falls back to - /// `"host-gateway"` (passed through unmodified) if resolution fails, which will - /// cause `container run` to emit a useful error rather than silently misbehaving. - static func resolveHostGatewayIP() -> String { + /// 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"] @@ -980,6 +1019,7 @@ extension ComposeUp { 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) @@ -989,6 +1029,30 @@ extension ComposeUp { } 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 From 34ed105ed77b896c4c9da8bf26187805555de826 Mon Sep 17 00:00:00 2001 From: Ryan Wilson Date: Wed, 1 Jul 2026 13:40:58 +0100 Subject: [PATCH 3/3] fix(service): warn and preserve self-hostname when extra_hosts replaces /etc/hosts Bind-mounting over /etc/hosts (required because container run has no --add-host to append to) replaces the daemon-generated file wholesale, including the entry for the container's own hostname. Print a note explaining this, and re-add -> 127.0.0.1 (or the resolved service.hostname when set) so self-resolution keeps working, skipping it if extra_hosts already defines that name explicitly. Also remove the generated hosts file in `down`, once the container it was mounted into is actually stopped -- doing it any earlier risks removing it out from under a still-running container's bind mount. --- .../Commands/ComposeDown.swift | 4 +++ .../Commands/ComposeUp.swift | 36 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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 672dcaf..0abfa5c 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -743,6 +743,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // 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 @@ -753,14 +761,27 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { 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 = NSTemporaryDirectory() + "container-compose-\(projectName)-\(serviceName)-hosts" + + 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"]) @@ -985,6 +1006,19 @@ 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