Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions Sources/ContainerResource/Container/ContainerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -70,6 +74,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case sysctls
case networks
case dns
case extraHosts
case rosetta
case initProcess
case platform
Expand Down Expand Up @@ -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
Expand All @@ -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"]

Expand Down
8 changes: 8 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ public struct Flags {
detach: Bool,
dns: Flags.DNS,
dnsDisabled: Bool,
extraHosts: [String] = [],
entrypoint: String?,
initImage: String?,
kernel: String?,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<firstColon])
let ipString = String(raw[raw.index(after: firstColon)...])
guard !hostname.isEmpty else {
throw ContainerizationError(
.invalidArgument,
message: "invalid --add-host value '\(raw)' (hostname is empty)"
)
}
guard !ipString.isEmpty else {
throw ContainerizationError(
.invalidArgument,
message: "invalid --add-host value '\(raw)' (IP address is empty)"
)
}
do {
_ = try IPAddress(ipString)
} catch {
throw ContainerizationError(
.invalidArgument,
message: "invalid --add-host value '\(raw)': '\(ipString)' is not a valid IPv4 or IPv6 address"
)
}
result.append(.init(hostname: hostname, ipAddress: ipString))
}
return result
}

public static func labels(_ rawLabels: [String]) throws -> [String: String] {
var result: [String: String] = [:]
for label in rawLabels {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
70 changes: 70 additions & 0 deletions Tests/ContainerAPIClientTests/ParserTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Testing

@testable import ContainerAPIClient
@testable import ContainerPersistence
@testable import ContainerResource

struct ParserTest {
@Test
Expand Down Expand Up @@ -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"])
}
}
Original file line number Diff line number Diff line change
@@ -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 == [])
}
}