From 344f68904aae42a546e316f4cfc96837e48565da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mart=C3=ADn=20Fern=C3=A1ndez?= Date: Fri, 15 May 2026 17:57:14 -0300 Subject: [PATCH] Add --add-host flag for static /etc/hosts entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container daemon currently builds a minimal `/etc/hosts` containing only loopback and the container's own primary-interface address — there is no way for a caller to inject arbitrary host-to-IP mappings. SandboxService even carries a comment hinting at this: // NOTE: We can support a user providing new entries eventually, but // for now craft a default /etc/hosts. Add a `--add-host host:ip` flag on `container create` / `container run` (repeatable, matching Docker's flag of the same name and compose's `extra_hosts`). The flag values flow through `Flags.Management` → `Parser.extraHosts` → `ContainerConfiguration.extraHosts` → SandboxService, where each entry is appended to the `/etc/hosts` written before user processes start. The parser mirrors the existing `--dns-*` plumbing: separate `@Option` on the `Management` flag group, validation via `IPAddress` (accepts both IPv4 and IPv6), splitting on the *first* `:` so that IPv6 addresses (which themselves contain `:`) parse correctly. DNS hostnames cannot contain `:`, so the first colon is unambiguous as the separator. `ContainerConfiguration.extraHosts` is decoded with `decodeIfPresent` and defaults to `[]`, so configurations encoded by older daemons (or clients that never set the field) decode unchanged. --- .../Container/ContainerConfiguration.swift | 19 +++++ .../ContainerAPIService/Client/Flags.swift | 8 ++ .../ContainerAPIService/Client/Parser.swift | 43 ++++++++++ .../ContainerAPIService/Client/Utility.swift | 2 + .../Server/SandboxService.swift | 13 ++- .../ContainerAPIClientTests/ParserTest.swift | 70 +++++++++++++++ ...ontainerConfigurationExtraHostsTests.swift | 85 +++++++++++++++++++ 7 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 Tests/ContainerResourceTests/ContainerConfigurationExtraHostsTests.swift diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index 5a6bd90e0..d3c0fbd7f 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -35,6 +35,10 @@ public struct ContainerConfiguration: Sendable, Codable { public var networks: [AttachmentConfiguration] = [] /// The DNS configuration for the container. public var dns: DNSConfiguration? = nil + /// Additional host-to-IP mappings to inject into the container's + /// `/etc/hosts` (the `--add-host` flag, equivalent to compose's + /// `extra_hosts`). + public var extraHosts: [ExtraHost] = [] /// Whether to enable rosetta x86-64 translation for the container. public var rosetta: Bool = false /// Initial or main process of the container. @@ -70,6 +74,7 @@ public struct ContainerConfiguration: Sendable, Codable { case sysctls case networks case dns + case extraHosts case rosetta case initProcess case platform @@ -104,6 +109,7 @@ public struct ContainerConfiguration: Sendable, Codable { } dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) + extraHosts = try container.decodeIfPresent([ExtraHost].self, forKey: .extraHosts) ?? [] rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false initProcess = try container.decode(ProcessConfiguration.self, forKey: .initProcess) platform = try container.decodeIfPresent(ContainerizationOCI.Platform.self, forKey: .platform) ?? .current @@ -118,6 +124,19 @@ public struct ContainerConfiguration: Sendable, Codable { shmSize = try container.decodeIfPresent(UInt64.self, forKey: .shmSize) } + /// A static host-to-IP mapping appended to the container's + /// `/etc/hosts` at boot. Populated from `--add-host host:ip` on + /// `container run`/`create` and from compose's `extra_hosts`. + public struct ExtraHost: Sendable, Codable, Equatable { + public let hostname: String + public let ipAddress: String + + public init(hostname: String, ipAddress: String) { + self.hostname = hostname + self.ipAddress = ipAddress + } + } + public struct DNSConfiguration: Sendable, Codable { public static let defaultNameservers = ["1.1.1.1"] diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index f8361ef68..24e717f92 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -173,6 +173,7 @@ public struct Flags { detach: Bool, dns: Flags.DNS, dnsDisabled: Bool, + extraHosts: [String] = [], entrypoint: String?, initImage: String?, kernel: String?, @@ -201,6 +202,7 @@ public struct Flags { self.detach = detach self.dns = dns self.dnsDisabled = dnsDisabled + self.extraHosts = extraHosts self.entrypoint = entrypoint self.initImage = initImage self.kernel = kernel @@ -247,6 +249,12 @@ public struct Flags { @OptionGroup public var dns: Flags.DNS + @Option( + name: .customLong("add-host"), + help: .init("Add a custom host-to-IP mapping to /etc/hosts (format: host:ip)", valueName: "host:ip") + ) + public var extraHosts: [String] = [] + @Option( name: .long, help: .init( diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 76aee086e..10d5058b2 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -224,6 +224,49 @@ public struct Parser { return envVar } + /// Parse `--add-host host:ip` arguments into `ExtraHost` values. + /// + /// The IP may be IPv4 or IPv6. Splits on the first `:` so that + /// IPv6 addresses (which themselves contain `:`) parse correctly — + /// DNS hostnames cannot contain `:`, so the first `:` is always + /// the separator. Matches Docker's `--add-host` and compose's + /// `extra_hosts` semantics. + public static func extraHosts(_ rawExtraHosts: [String]) throws -> [ContainerConfiguration.ExtraHost] { + var result: [ContainerConfiguration.ExtraHost] = [] + for raw in rawExtraHosts { + guard let firstColon = raw.firstIndex(of: ":") else { + throw ContainerizationError( + .invalidArgument, + message: "invalid --add-host value '\(raw)' (expected host:ip)" + ) + } + let hostname = String(raw[.. [String: String] { var result: [String: String] = [:] for label in rawLabels { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 87c240824..304dc96d0 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -235,6 +235,8 @@ public struct Utility { ) } + config.extraHosts = try Parser.extraHosts(management.extraHosts) + config.rosetta = management.rosetta || (Platform.current.architecture == "arm64" && requestedPlatform.architecture == "amd64") if management.rosetta && Platform.current.architecture != "arm64" { diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index e65b09a45..e30918692 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -259,8 +259,10 @@ public actor SandboxService { czConfig.process.stdout = stdout czConfig.process.stderr = stderr czConfig.process.stdin = stdin - // NOTE: We can support a user providing new entries eventually, but for now craft - // a default /etc/hosts. + // Build the default /etc/hosts (loopback + the + // container's own primary-interface address), then + // append any user-supplied `--add-host` entries from + // the container configuration. var hostsEntries = [Hosts.Entry.localHostIPV4()] if !interfaces.isEmpty { let primaryIfaceAddr = interfaces[0].ipv4Address @@ -270,6 +272,13 @@ public actor SandboxService { hostnames: [czConfig.hostname ?? id], )) } + for extra in config.extraHosts { + hostsEntries.append( + Hosts.Entry( + ipAddress: extra.ipAddress, + hostnames: [extra.hostname], + )) + } czConfig.hosts = Hosts(entries: hostsEntries) czConfig.bootLog = BootLog.file(path: bundle.bootlog, append: true) } diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 8a1deabbe..f7ea4c2d3 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -22,6 +22,7 @@ import Testing @testable import ContainerAPIClient @testable import ContainerPersistence +@testable import ContainerResource struct ParserTest { @Test @@ -1336,4 +1337,73 @@ struct ParserTest { func testManagementFlagsAcceptsNoDNSAlone() throws { _ = try Flags.Management.parse(["--no-dns"]) } + + @Test + func testExtraHostsParserIPv4() throws { + let result = try Parser.extraHosts(["db:192.168.66.2"]) + #expect(result == [.init(hostname: "db", ipAddress: "192.168.66.2")]) + } + + @Test + func testExtraHostsParserIPv6() throws { + let result = try Parser.extraHosts(["db:fe80::1"]) + #expect(result == [.init(hostname: "db", ipAddress: "fe80::1")]) + } + + @Test + func testExtraHostsParserMultiple() throws { + let result = try Parser.extraHosts([ + "db:10.0.0.5", + "cache:10.0.0.6", + "api.example.com:10.0.0.7", + ]) + #expect(result.count == 3) + #expect(result[0].hostname == "db") + #expect(result[2].hostname == "api.example.com") + } + + @Test + func testExtraHostsParserIPv6FullForm() throws { + // IPv6 addresses contain `:`; split must be on the FIRST `:` + // (DNS hostnames cannot contain `:`, so this is unambiguous). + let result = try Parser.extraHosts(["host:2001:db8::1"]) + #expect(result == [.init(hostname: "host", ipAddress: "2001:db8::1")]) + } + + @Test + func testExtraHostsParserRejectsMissingColon() throws { + #expect(throws: (any Error).self) { + _ = try Parser.extraHosts(["db-192.168.1.1"]) + } + } + + @Test + func testExtraHostsParserRejectsEmptyHostname() throws { + #expect(throws: (any Error).self) { + _ = try Parser.extraHosts([":192.168.1.1"]) + } + } + + @Test + func testExtraHostsParserRejectsEmptyIP() throws { + #expect(throws: (any Error).self) { + _ = try Parser.extraHosts(["db:"]) + } + } + + @Test + func testExtraHostsParserRejectsInvalidIP() throws { + #expect(throws: (any Error).self) { + _ = try Parser.extraHosts(["db:not-an-ip"]) + } + } + + @Test + func testManagementFlagsAcceptsAddHost() throws { + let flags = try Flags.Management.parse([ + "--add-host", "db:192.168.66.2", + "--add-host", "cache:192.168.66.3", + ]) + #expect(flags.extraHosts == ["db:192.168.66.2", "cache:192.168.66.3"]) + } } diff --git a/Tests/ContainerResourceTests/ContainerConfigurationExtraHostsTests.swift b/Tests/ContainerResourceTests/ContainerConfigurationExtraHostsTests.swift new file mode 100644 index 000000000..32aae0810 --- /dev/null +++ b/Tests/ContainerResourceTests/ContainerConfigurationExtraHostsTests.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// 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 ContainerizationOCI +import Foundation +import Testing + +@testable import ContainerResource + +struct ContainerConfigurationExtraHostsTests { + + private func makeConfig() -> ContainerConfiguration { + let image = ImageDescription( + reference: "docker.io/library/alpine:latest", + descriptor: .init( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000", + size: 0 + ) + ) + let process = ProcessConfiguration( + executable: "/bin/sh", + arguments: [], + environment: [] + ) + return ContainerConfiguration(id: "abc123", image: image, process: process) + } + + /// A configuration encoded by an older daemon will not contain the + /// `extraHosts` key at all. Decoding such payload must succeed and + /// default `extraHosts` to `[]`. + @Test("Legacy configuration JSON decodes with empty extraHosts") + func legacyConfigDecodes() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let original = makeConfig() + let data = try encoder.encode(original) + + guard var dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Issue.record("encoded configuration was not a JSON object") + return + } + dict.removeValue(forKey: "extraHosts") + let strippedData = try JSONSerialization.data(withJSONObject: dict) + + let decoded = try decoder.decode(ContainerConfiguration.self, from: strippedData) + #expect(decoded.extraHosts == []) + } + + @Test("Populated extraHosts round-trip through JSON") + func extraHostsRoundTrip() throws { + var config = makeConfig() + config.extraHosts = [ + .init(hostname: "db", ipAddress: "192.168.66.2"), + .init(hostname: "cache", ipAddress: "fe80::1"), + ] + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(config) + let decoded = try decoder.decode(ContainerConfiguration.self, from: data) + + #expect(decoded.extraHosts == config.extraHosts) + } + + @Test("Default configuration has empty extraHosts") + func defaultExtraHostsIsEmpty() { + let config = makeConfig() + #expect(config.extraHosts == []) + } +}