diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 101a7bba..cfd3ef7f 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -51,6 +51,12 @@ public final class LinuxPod: Sendable { /// Whether containers in the pod should share a PID namespace. /// When enabled, all containers can see each other's processes. public var shareProcessNamespace: Bool = false + /// The default DNS configuration for all containers in the pod. + /// Individual containers can override this by setting their own `dns` configuration. + public var dns: DNS? + /// The default hosts file configuration for all containers in the pod. + /// Individual containers can override this by setting their own `hosts` configuration. + public var hosts: Hosts? public init() {} } @@ -435,15 +441,16 @@ extension LinuxPod { } } - // Setup /etc/resolv.conf and /etc/hosts for each container + // Setup /etc/resolv.conf and /etc/hosts for each container. + // Container-level config takes precedence over pod-level config. for (_, container) in containers { - if let dns = container.config.dns { + if let dns = container.config.dns ?? self.config.dns { try await agent.configureDNS( config: dns, location: Self.guestRootfsPath(container.id) ) } - if let hosts = container.config.hosts { + if let hosts = container.config.hosts ?? self.config.hosts { try await agent.configureHosts( config: hosts, location: Self.guestRootfsPath(container.id) diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 155a8f42..db7d367d 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -1089,4 +1089,262 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "container2 should NOT have service-a entry, got: \(output2)") } } + + func testPodLevelDNS() async throws { + let id = "test-pod-level-dns" + + let bs = try await bootstrap(id) + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + // Set DNS at the pod level + config.dns = DNS(nameservers: ["9.9.9.9", "149.112.112.112"]) + } + + let buffer1 = BufferWriter() + let buffer2 = BufferWriter() + + // Neither container specifies DNS. We should inherit from pod + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["cat", "/etc/resolv.conf"] + config.process.stdout = buffer1 + } + + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["cat", "/etc/resolv.conf"] + config.process.stdout = buffer2 + } + + try await pod.create() + + try await pod.startContainer("container1") + let status1 = try await pod.waitContainer("container1") + + try await pod.startContainer("container2") + let status2 = try await pod.waitContainer("container2") + + try await pod.stop() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "container1 cat failed with status \(status1)") + } + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "container2 cat failed with status \(status2)") + } + + guard let output1 = String(data: buffer1.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container1 stdout to UTF8") + } + guard let output2 = String(data: buffer2.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container2 stdout to UTF8") + } + + // Both containers should have the pod-level DNS + guard output1.contains("9.9.9.9") && output1.contains("149.112.112.112") else { + throw IntegrationError.assert(msg: "container1 should have pod-level DNS (9.9.9.9), got: \(output1)") + } + guard output2.contains("9.9.9.9") && output2.contains("149.112.112.112") else { + throw IntegrationError.assert(msg: "container2 should have pod-level DNS (9.9.9.9), got: \(output2)") + } + } + + func testPodLevelDNSWithContainerOverride() async throws { + let id = "test-pod-level-dns-override" + + let bs = try await bootstrap(id) + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + // Set DNS at the pod level + config.dns = DNS(nameservers: ["9.9.9.9"]) + } + + let buffer1 = BufferWriter() + let buffer2 = BufferWriter() + + // Container1 does NOT specify DNS. It should inherit from pod + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["cat", "/etc/resolv.conf"] + config.process.stdout = buffer1 + } + + // Container2 specifies its own DNS. It should override pod-level + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["cat", "/etc/resolv.conf"] + config.process.stdout = buffer2 + config.dns = DNS(nameservers: ["8.8.8.8"]) + } + + try await pod.create() + + try await pod.startContainer("container1") + let status1 = try await pod.waitContainer("container1") + + try await pod.startContainer("container2") + let status2 = try await pod.waitContainer("container2") + + try await pod.stop() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "container1 cat failed with status \(status1)") + } + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "container2 cat failed with status \(status2)") + } + + guard let output1 = String(data: buffer1.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container1 stdout to UTF8") + } + guard let output2 = String(data: buffer2.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container2 stdout to UTF8") + } + + // Container1 should have pod-level DNS + guard output1.contains("9.9.9.9") && !output1.contains("8.8.8.8") else { + throw IntegrationError.assert(msg: "container1 should have pod-level DNS (9.9.9.9), got: \(output1)") + } + // Container2 should have its own DNS, not pod-level + guard output2.contains("8.8.8.8") && !output2.contains("9.9.9.9") else { + throw IntegrationError.assert(msg: "container2 should have container-level DNS (8.8.8.8), got: \(output2)") + } + } + + func testPodLevelHosts() async throws { + let id = "test-pod-level-hosts" + + let bs = try await bootstrap(id) + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + // Set hosts at the pod level + config.hosts = Hosts(entries: [ + Hosts.Entry.localHostIPV4(), + Hosts.Entry(ipAddress: "10.0.0.100", hostnames: ["shared-service.local"]), + ]) + } + + let buffer1 = BufferWriter() + let buffer2 = BufferWriter() + + // Neither container specifies hosts. It should inherit from pod + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["cat", "/etc/hosts"] + config.process.stdout = buffer1 + } + + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["cat", "/etc/hosts"] + config.process.stdout = buffer2 + } + + try await pod.create() + + try await pod.startContainer("container1") + let status1 = try await pod.waitContainer("container1") + + try await pod.startContainer("container2") + let status2 = try await pod.waitContainer("container2") + + try await pod.stop() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "container1 cat failed with status \(status1)") + } + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "container2 cat failed with status \(status2)") + } + + guard let output1 = String(data: buffer1.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container1 stdout to UTF8") + } + guard let output2 = String(data: buffer2.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container2 stdout to UTF8") + } + + // Both containers should have the pod-level hosts entry + guard output1.contains("10.0.0.100") && output1.contains("shared-service.local") else { + throw IntegrationError.assert(msg: "container1 should have pod-level hosts entry, got: \(output1)") + } + guard output2.contains("10.0.0.100") && output2.contains("shared-service.local") else { + throw IntegrationError.assert(msg: "container2 should have pod-level hosts entry, got: \(output2)") + } + } + + func testPodLevelHostsWithContainerOverride() async throws { + let id = "test-pod-level-hosts-override" + + let bs = try await bootstrap(id) + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + // Set hosts at the pod level + config.hosts = Hosts(entries: [ + Hosts.Entry.localHostIPV4(), + Hosts.Entry(ipAddress: "10.0.0.100", hostnames: ["shared-service.local"]), + ]) + } + + let buffer1 = BufferWriter() + let buffer2 = BufferWriter() + + // Container1 does NOT specify hosts. It should inherit from pod + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["cat", "/etc/hosts"] + config.process.stdout = buffer1 + } + + // Container2 specifies its own hosts. It should override pod-level + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["cat", "/etc/hosts"] + config.process.stdout = buffer2 + config.hosts = Hosts(entries: [ + Hosts.Entry.localHostIPV4(), + Hosts.Entry(ipAddress: "10.0.0.200", hostnames: ["container-specific.local"]), + ]) + } + + try await pod.create() + + try await pod.startContainer("container1") + let status1 = try await pod.waitContainer("container1") + + try await pod.startContainer("container2") + let status2 = try await pod.waitContainer("container2") + + try await pod.stop() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "container1 cat failed with status \(status1)") + } + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "container2 cat failed with status \(status2)") + } + + guard let output1 = String(data: buffer1.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container1 stdout to UTF8") + } + guard let output2 = String(data: buffer2.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert container2 stdout to UTF8") + } + + // Container1 should have pod-level hosts entry + guard output1.contains("10.0.0.100") && output1.contains("shared-service.local") else { + throw IntegrationError.assert(msg: "container1 should have pod-level hosts entry, got: \(output1)") + } + guard !output1.contains("10.0.0.200") && !output1.contains("container-specific.local") else { + throw IntegrationError.assert(msg: "container1 should NOT have container2's hosts entry, got: \(output1)") + } + + // Container2 should have its own hosts entry, not pod-level + guard output2.contains("10.0.0.200") && output2.contains("container-specific.local") else { + throw IntegrationError.assert(msg: "container2 should have container-level hosts entry, got: \(output2)") + } + guard !output2.contains("10.0.0.100") && !output2.contains("shared-service.local") else { + throw IntegrationError.assert(msg: "container2 should NOT have pod-level hosts entry, got: \(output2)") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index d9f5a79b..d252c3b3 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -358,6 +358,10 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod container hosts config", testPodContainerHostsConfig), Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS), Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts), + Test("pod level DNS", testPodLevelDNS), + Test("pod level DNS with container override", testPodLevelDNSWithContainerOverride), + Test("pod level hosts", testPodLevelHosts), + Test("pod level hosts with container override", testPodLevelHostsWithContainerOverride), ] + macOS26Tests() let filteredTests: [Test]