diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index c216bc26b..ed1b3d6d9 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -171,6 +171,9 @@ extension Application { progress.set(description: "Dialing builder") let dnsNameservers = self.dns.nameservers + let dnsDomain = self.dns.domain + let dnsSearchDomains = self.dns.searchDomains + let dnsOptions = self.dns.options // Ensure the builder is started (or restarted) with the correct SSH configuration // before attempting to dial. This handles the case where the builder is already @@ -181,16 +184,20 @@ extension Application { log: log, ssh: ssh == "default", dnsNameservers: dnsNameservers, + dnsDomain: dnsDomain, + dnsSearchDomains: dnsSearchDomains, + dnsOptions: dnsOptions, progressUpdate: progress.handler, containerSystemConfig: containerSystemConfig, ) - let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers, ssh] group in + let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { + [vsockPort, cpus, memory, dnsNameservers, dnsDomain, dnsSearchDomains, dnsOptions, ssh] group in defer { group.cancelAll() } - group.addTask { [vsockPort, cpus, memory, log, dnsNameservers, ssh] in + group.addTask { [vsockPort, cpus, memory, log, dnsNameservers, dnsDomain, dnsSearchDomains, dnsOptions, ssh] in let client = ContainerClient() while true { do { @@ -214,6 +221,9 @@ extension Application { log: log, ssh: ssh == "default", dnsNameservers: dnsNameservers, + dnsDomain: dnsDomain, + dnsSearchDomains: dnsSearchDomains, + dnsOptions: dnsOptions, progressUpdate: progress.handler, containerSystemConfig: containerSystemConfig, ) diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index f8bf20972..caf8910b4 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -92,6 +92,16 @@ extension Application { progressUpdate: @escaping ProgressUpdateHandler, containerSystemConfig: ContainerSystemConfig, ) async throws { + let dns = Utility.dnsConfiguration( + from: .init( + domain: dnsDomain, + nameservers: dnsNameservers, + options: dnsOptions, + searchDomains: dnsSearchDomains + ), + defaults: containerSystemConfig.container.dns + ) + await progressUpdate([ .setDescription("Fetching BuildKit image"), .setItemsName("blobs"), @@ -154,21 +164,7 @@ extension Application { let sshForwarded = existingContainer.configuration.ssh let sshWanted = ssh && ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil let sshChanged = sshForwarded != sshWanted - let dnsChanged = { - if !dnsNameservers.isEmpty { - return existingDNS?.nameservers != dnsNameservers - } - if dnsDomain != nil { - return existingDNS?.domain != dnsDomain - } - if !dnsSearchDomains.isEmpty { - return existingDNS?.searchDomains != dnsSearchDomains - } - if !dnsOptions.isEmpty { - return existingDNS?.options != dnsOptions - } - return false - }() + let dnsChanged = Self.dnsConfigurationChanged(existing: existingDNS, desired: dns) switch existingContainer.status { case .running: @@ -288,10 +284,10 @@ extension Application { AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId)) ] config.dns = ContainerConfiguration.DNSConfiguration( - nameservers: dnsNameservers, - domain: dnsDomain, - searchDomains: dnsSearchDomains, - options: dnsOptions + nameservers: dns.nameservers, + domain: dns.domain, + searchDomains: dns.searchDomains, + options: dns.options ) let kernel = try await { @@ -323,6 +319,17 @@ extension Application { try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager) log.debug("starting BuildKit and BuildKit-shim") } + + static func dnsConfigurationChanged( + existing: ContainerConfiguration.DNSConfiguration?, + desired: ContainerConfiguration.DNSConfiguration + ) -> Bool { + let existing = existing ?? .init() + return existing.nameservers != desired.nameservers + || existing.domain != desired.domain + || existing.searchDomains != desired.searchDomains + || existing.options != desired.options + } } } diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index cd3a156f9..5381821b8 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -118,16 +118,23 @@ final public class ContainerConfig: Codable, Sendable { public let cpus: Int public let memory: MemorySize + public let dns: ContainerDNSConfig? - public init(cpus: Int = defaultCPUs, memory: MemorySize = defaultMemory) { + public init( + cpus: Int = defaultCPUs, + memory: MemorySize = defaultMemory, + dns: ContainerDNSConfig? = nil + ) { self.cpus = cpus self.memory = memory + self.dns = dns } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory + self.dns = try container.decodeIfPresent(ContainerDNSConfig.self, forKey: .dns) } } @@ -144,6 +151,33 @@ final public class DNSConfig: Codable, Sendable { } } +final public class ContainerDNSConfig: Codable, Sendable { + public let domain: String? + public let nameservers: [String]? + public let searchDomains: [String]? + public let options: [String]? + + public init( + domain: String? = nil, + nameservers: [String]? = nil, + searchDomains: [String]? = nil, + options: [String]? = nil + ) { + self.domain = domain + self.nameservers = nameservers + self.searchDomains = searchDomains + self.options = options + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decodeIfPresent(String.self, forKey: .domain) + self.nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) + self.searchDomains = try container.decodeIfPresent([String].self, forKey: .searchDomains) + self.options = try container.decodeIfPresent([String].self, forKey: .options) + } +} + final public class VminitConfig: Codable, Sendable { public static var defaultImage: String { let tag = String(cString: get_swift_containerization_version()) diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index f6329c35a..b0b7831e8 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -219,12 +219,10 @@ public struct Utility { if management.dnsDisabled { config.dns = nil } else { - let domain = management.dns.domain ?? containerSystemConfig.dns.domain - config.dns = .init( - nameservers: management.dns.nameservers, - domain: domain, - searchDomains: management.dns.searchDomains, - options: management.dns.options + config.dns = dnsConfiguration( + from: management.dns, + defaults: containerSystemConfig.container.dns, + hostDomainFallback: containerSystemConfig.dns.domain ) } @@ -328,6 +326,39 @@ public struct Utility { return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] } + /// Resolves the DNS configuration for a container from CLI flags, falling back + /// to the defaults configured in `~/.config/container/config.toml`. + /// + /// Precedence: CLI flags > `[container.dns]` defaults > host domain fallback. + /// `hostDomainFallback` is the API server's own DNS domain (the `[dns]` section), + /// used so a container's default domain still matches the host domain when no + /// `--dns-domain` flag and no `[container.dns]` domain are configured. + public static func dnsConfiguration( + from flags: Flags.DNS, + defaults: ContainerDNSConfig?, + hostDomainFallback: String? = nil + ) -> ContainerConfiguration.DNSConfiguration { + let nameservers = + flags.nameservers.isEmpty + ? (defaults?.nameservers ?? []) + : flags.nameservers + let domain = flags.domain ?? defaults?.domain ?? hostDomainFallback + let searchDomains = + flags.searchDomains.isEmpty + ? (defaults?.searchDomains ?? []) + : flags.searchDomains + let options = + flags.options.isEmpty + ? (defaults?.options ?? []) + : flags.options + return .init( + nameservers: nameservers, + domain: domain, + searchDomains: searchDomains, + options: options + ) + } + private static func getKernel(management: Flags.Management) async throws -> Kernel { // For the image itself we'll take the user input and try with it as we can do userspace // emulation for x86, but for the kernel we need it to match the hosts architecture. diff --git a/Tests/ContainerAPIClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift index 3a9b3a495..305386d73 100644 --- a/Tests/ContainerAPIClientTests/UtilityTests.swift +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerPersistence import ContainerResource import ContainerizationError import Foundation @@ -113,6 +114,106 @@ struct UtilityTests { #expect(Utility.trimDigest(digest: "sha256:abc") == "abc") } + @Test + func testDNSConfigurationUsesDefaultsWhenFlagsAbsent() { + let defaults = ContainerDNSConfig( + domain: "corp.local", + nameservers: ["1.1.1.1"], + searchDomains: ["corp.local"], + options: ["ndots:2"] + ) + + let flags = Flags.DNS( + domain: nil, + nameservers: [], + options: [], + searchDomains: [] + ) + + let result = Utility.dnsConfiguration(from: flags, defaults: defaults) + + #expect(result.domain == "corp.local") + #expect(result.nameservers == ["1.1.1.1"]) + #expect(result.searchDomains == ["corp.local"]) + #expect(result.options == ["ndots:2"]) + } + + @Test + func testDNSConfigurationFlagsOverrideDefaults() { + let defaults = ContainerDNSConfig( + domain: "corp.local", + nameservers: ["1.1.1.1"], + searchDomains: ["corp.local"], + options: ["ndots:2"] + ) + let flags = Flags.DNS( + domain: "cli.local", + nameservers: ["8.8.8.8"], + options: ["debug"], + searchDomains: ["cli.local"] + ) + + let result = Utility.dnsConfiguration(from: flags, defaults: defaults) + + #expect(result.domain == "cli.local") + #expect(result.nameservers == ["8.8.8.8"]) + #expect(result.searchDomains == ["cli.local"]) + #expect(result.options == ["debug"]) + } + + @Test + func testDNSConfigurationPartialFlagsFallbackToDefaults() { + let defaults = ContainerDNSConfig( + domain: "corp.local", + nameservers: ["1.1.1.1"], + searchDomains: ["corp.local"], + options: ["ndots:2"] + ) + let flags = Flags.DNS( + domain: nil, + nameservers: ["8.8.8.8"], + options: [], + searchDomains: [] + ) + + let result = Utility.dnsConfiguration(from: flags, defaults: defaults) + + #expect(result.domain == "corp.local") + #expect(result.nameservers == ["8.8.8.8"]) + #expect(result.searchDomains == ["corp.local"]) + #expect(result.options == ["ndots:2"]) + } + + @Test + func testDNSConfigurationDomainFallsBackToHostDomain() { + let defaults = ContainerDNSConfig() + let flags = Flags.DNS( + domain: nil, + nameservers: [], + options: [], + searchDomains: [] + ) + + let result = Utility.dnsConfiguration(from: flags, defaults: defaults, hostDomainFallback: "host.local") + + #expect(result.domain == "host.local") + } + + @Test + func testDNSConfigurationContainerDomainWinsOverHostDomain() { + let defaults = ContainerDNSConfig(domain: "container.local") + let flags = Flags.DNS( + domain: nil, + nameservers: [], + options: [], + searchDomains: [] + ) + + let result = Utility.dnsConfiguration(from: flags, defaults: defaults, hostDomainFallback: "host.local") + + #expect(result.domain == "container.local") + } + @Test func testPublishPortParser() throws { let ports = try Parser.publishPorts([ diff --git a/Tests/ContainerCommandsTests/BuilderStartTests.swift b/Tests/ContainerCommandsTests/BuilderStartTests.swift new file mode 100644 index 000000000..5a79516cb --- /dev/null +++ b/Tests/ContainerCommandsTests/BuilderStartTests.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import Testing + +@testable import ContainerCommands + +struct BuilderStartTests { + @Test + func dnsConfigurationChangedTreatsMissingAndEmptyAsEquivalent() { + let desired = ContainerConfiguration.DNSConfiguration() + + #expect(!Application.BuilderStart.dnsConfigurationChanged(existing: nil, desired: desired)) + } + + @Test + func dnsConfigurationChangedDetectsRemovedConfiguration() { + let existing = ContainerConfiguration.DNSConfiguration( + nameservers: ["1.1.1.1"], + domain: "corp.local", + searchDomains: ["corp.local"], + options: ["ndots:2"] + ) + let desired = ContainerConfiguration.DNSConfiguration() + + #expect(Application.BuilderStart.dnsConfigurationChanged(existing: existing, desired: desired)) + } + + @Test + func dnsConfigurationChangedDetectsAddedConfiguration() { + let desired = ContainerConfiguration.DNSConfiguration( + nameservers: ["8.8.8.8"], + domain: "lab.local", + searchDomains: ["lab.local"], + options: ["timeout:1"] + ) + + #expect(Application.BuilderStart.dnsConfigurationChanged(existing: nil, desired: desired)) + } +} diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift index 347c41695..e2aff665e 100644 --- a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -90,6 +90,10 @@ struct ConfigurationLoaderTests { #expect(config.build.memory == BuildConfig.defaultMemory) #expect(config.container.cpus == 4) #expect(config.container.memory == ContainerConfig.defaultMemory) + #expect(config.container.dns?.domain == nil) + #expect(config.container.dns?.nameservers == nil) + #expect(config.container.dns?.searchDomains == nil) + #expect(config.container.dns?.options == nil) #expect(config.dns.domain == nil) #expect(!config.build.image.isEmpty) #expect(!config.vminit.image.isEmpty) @@ -115,8 +119,14 @@ struct ConfigurationLoaderTests { cpus = 16 memory = "8g" + [container.dns] + domain = "container.custom" + nameservers = ["1.1.1.1", "8.8.8.8"] + searchDomains = ["corp.local", "lab.corp.local"] + options = ["ndots:2", "timeout:1"] + [dns] - domain = "custom" + domain = "host.custom" [kernel] binaryPath = "custom/path" @@ -144,7 +154,11 @@ struct ConfigurationLoaderTests { #expect(config.container.cpus == 16) let expectedContainerMemory = try MemorySize("8g") #expect(config.container.memory == expectedContainerMemory) - #expect(config.dns.domain == "custom") + #expect(config.container.dns?.domain == "container.custom") + #expect(config.container.dns?.nameservers == ["1.1.1.1", "8.8.8.8"]) + #expect(config.container.dns?.searchDomains == ["corp.local", "lab.corp.local"]) + #expect(config.container.dns?.options == ["ndots:2", "timeout:1"]) + #expect(config.dns.domain == "host.custom") #expect(config.build.image == "custom-builder:latest") #expect(config.vminit.image == "custom-init:latest") #expect(config.kernel.binaryPath == "custom/path") @@ -221,6 +235,24 @@ struct ConfigurationLoaderTests { #expect(config.digest == digest) } + @Test func containerDNSArraysLoadFromTomlIndependently() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [container.dns] + nameservers = ["1.1.1.1", "8.8.8.8"] + """ + let tmpFile = tempDir.appending("dns.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.container.dns?.nameservers == ["1.1.1.1", "8.8.8.8"]) + #expect(config.container.dns?.domain == nil) + #expect(config.container.dns?.searchDomains == nil) + #expect(config.container.dns?.options == nil) + #expect(config.dns.domain == nil) + } + } + @Test func unknownKeysIgnored() async throws { try await TemporaryStorage.withTempDir { tempDir in let toml = """ diff --git a/docs/container-system-config.md b/docs/container-system-config.md index f6516640b..7966dd230 100644 --- a/docs/container-system-config.md +++ b/docs/container-system-config.md @@ -91,6 +91,19 @@ Defaults applied when `container run` / `container create` is invoked without `- | `cpus` | `Int` | `4` | Default CPU count per container. | | `memory` | [MemorySize](#memorysize-format) | `"1g"` | Default RAM per container. | +### `[container.dns]` + +Default DNS settings applied when `container run` / `container build` / `container builder start` is invoked without `--dns`, `--dns-search`, `--dns-option`, or `--dns-domain`. CLI flags override these values. + +| Key | Type | Default | Description | +|----------------|-------------|---------|----------------------------------------------------------------------| +| `domain` | `String?` | unset | DNS domain appended to container hostnames. | +| `nameservers` | `[String]?` | unset | DNS nameservers configured in the container's resolver. | +| `searchDomains`| `[String]?` | unset | DNS search domains configured in the container's resolver. | +| `options` | `[String]?` | unset | DNS options (e.g. `"ndots:2"`) configured in the container's resolver. | + +See [Networking: Set default DNS settings for containers](./networking.md#set-default-dns-settings-for-containers) for the walkthrough. + ## `[dns]` | Key | Type | Default | Description | diff --git a/docs/networking.md b/docs/networking.md index db21fe27c..9c22f834a 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -59,6 +59,25 @@ With both steps done, confirm it end-to-end from your Mac: See [Host integration](./host-integration.md) for the reverse direction — reaching a service running on your Mac from inside a container. +## Set default DNS settings for containers + +To avoid passing `--dns`, `--dns-search`, and `--dns-option` on every `container run`, +`container build`, or `container builder start` invocation, set defaults in +`~/.config/container/config.toml`: + +```toml +[container.dns] +domain = "corp.local" +nameservers = ["1.1.1.1", "8.8.8.8"] +searchDomains = ["corp.local", "lab.corp.local"] +options = ["ndots:2", "timeout:1"] +``` + +CLI flags override these defaults when provided. Use `container run --no-dns` to skip DNS +configuration entirely. The top-level [`[dns]`](./container-system-config.md#dns) section +configures the internal DNS domain used by the host and API server. Restart the daemon +(`container system stop && container system start`) for changes to take effect. + ## Container-to-container networking From one container, use another container's DNS name to reach a service it exposes.