diff --git a/Package.swift b/Package.swift index 1bbb3c309..2385c0b22 100644 --- a/Package.swift +++ b/Package.swift @@ -391,6 +391,7 @@ let package = Package( dependencies: [ .product(name: "Containerization", package: "containerization"), "ContainerResource", + "ContainerSandboxService", "ContainerSandboxServiceClient", ] ), diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index c4c3da6b9..ff97804cf 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -35,6 +35,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var networks: [AttachmentConfiguration] = [] /// The DNS configuration for the container. public var dns: DNSConfiguration? = nil + /// Additional hosts entries to inject into /etc/hosts. + public var hosts: [HostEntry] = [] /// Whether to enable rosetta x86-64 translation for the container. public var rosetta: Bool = false /// Initial or main process of the container. @@ -64,6 +66,7 @@ public struct ContainerConfiguration: Sendable, Codable { case sysctls case networks case dns + case hosts case rosetta case initProcess case platform @@ -95,6 +98,7 @@ public struct ContainerConfiguration: Sendable, Codable { } dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) + hosts = try container.decodeIfPresent([HostEntry].self, forKey: .hosts) ?? [] 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 @@ -127,6 +131,16 @@ public struct ContainerConfiguration: Sendable, Codable { } } + public struct HostEntry: Sendable, Codable, Equatable { + public let ipAddress: String + public let hostnames: [String] + + public init(ipAddress: String, hostnames: [String]) { + self.ipAddress = ipAddress + self.hostnames = hostnames + } + } + /// Resources like cpu, memory, and storage quota. public struct Resources: Sendable, Codable { /// Number of CPU cores allocated. diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index db8acd03c..397f6aa01 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -220,18 +220,13 @@ 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. - var hostsEntries = [Hosts.Entry.localHostIPV4()] - if !interfaces.isEmpty { - let primaryIfaceAddr = interfaces[0].ipv4Address - hostsEntries.append( - Hosts.Entry( - ipAddress: primaryIfaceAddr.address.description, - hostnames: [czConfig.hostname ?? id], - )) - } - czConfig.hosts = Hosts(entries: hostsEntries) + czConfig.hosts = Hosts(entries: Self.resolvedHosts( + hostname: czConfig.hostname ?? id, + primaryAddress: interfaces.first?.ipv4Address.address.description, + extraHosts: config.hosts + ).map { + Hosts.Entry(ipAddress: $0.ipAddress, hostnames: $0.hostnames) + }) czConfig.bootLog = BootLog.file(path: bundle.bootlog, append: true) } @@ -1292,6 +1287,22 @@ extension FileHandle: @retroactive ReaderStream, @retroactive Writer { // MARK: State handler and bundle creation helpers extension SandboxService { + static func resolvedHosts( + hostname: String, + primaryAddress: String?, + extraHosts: [ContainerConfiguration.HostEntry] + ) -> [ContainerConfiguration.HostEntry] { + var hosts = [ContainerConfiguration.HostEntry(ipAddress: "127.0.0.1", hostnames: ["localhost"])] + + if let primaryAddress { + let ip = String(primaryAddress.split(separator: "/")[0]) + hosts.append(ContainerConfiguration.HostEntry(ipAddress: ip, hostnames: [hostname])) + } + + hosts.append(contentsOf: extraHosts) + return hosts + } + private func initializeWaiters(for id: String) throws { guard waiters[id] == nil else { throw ContainerizationError(.invalidState, message: "waiter for \(id) already initialized") diff --git a/Tests/ContainerResourceTests/ContainerConfigurationTest.swift b/Tests/ContainerResourceTests/ContainerConfigurationTest.swift new file mode 100644 index 000000000..45f96244e --- /dev/null +++ b/Tests/ContainerResourceTests/ContainerConfigurationTest.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing +import ContainerizationOCI + +@testable import ContainerResource + +struct ContainerConfigurationTest { + @Test + func testContainerConfigurationRoundTripsHosts() throws { + let image = ImageDescription( + reference: "docker.io/library/alpine:latest", + descriptor: Descriptor( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + size: 123 + ) + ) + var configuration = ContainerConfiguration( + id: "web", + image: image, + process: ProcessConfiguration( + executable: "/bin/sh", + arguments: ["-c", "sleep infinity"], + environment: [] + ) + ) + configuration.hosts = [ + ContainerConfiguration.HostEntry(ipAddress: "127.0.0.1", hostnames: ["localhost"]), + ContainerConfiguration.HostEntry(ipAddress: "192.168.64.1", hostnames: ["host.docker.internal"]), + ] + + let encoded = try JSONEncoder().encode(configuration) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: encoded) + + #expect(decoded.hosts == configuration.hosts) + } +} diff --git a/Tests/ContainerSandboxServiceTests/SandboxServiceHostsTest.swift b/Tests/ContainerSandboxServiceTests/SandboxServiceHostsTest.swift new file mode 100644 index 000000000..d3cb94c57 --- /dev/null +++ b/Tests/ContainerSandboxServiceTests/SandboxServiceHostsTest.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerResource +@testable import ContainerSandboxService + +struct SandboxServiceHostsTest { + @Test + func testResolvedHostsIncludesDefaultsPrimaryAddressAndExtraHosts() { + let extraHosts = [ + ContainerConfiguration.HostEntry(ipAddress: "192.168.64.1", hostnames: ["host.docker.internal"]), + ContainerConfiguration.HostEntry(ipAddress: "10.0.0.15", hostnames: ["db", "db.internal"]), + ] + + let hosts = SandboxService.resolvedHosts( + hostname: "web", + primaryAddress: "192.168.64.22/24", + extraHosts: extraHosts + ) + + #expect(hosts == [ + ContainerConfiguration.HostEntry(ipAddress: "127.0.0.1", hostnames: ["localhost"]), + ContainerConfiguration.HostEntry(ipAddress: "192.168.64.22", hostnames: ["web"]), + ContainerConfiguration.HostEntry(ipAddress: "192.168.64.1", hostnames: ["host.docker.internal"]), + ContainerConfiguration.HostEntry(ipAddress: "10.0.0.15", hostnames: ["db", "db.internal"]), + ]) + } +}