From 1e42796506a26a72b676f9724e1a7520783e70f1 Mon Sep 17 00:00:00 2001 From: jamu85 Date: Fri, 29 May 2026 15:25:52 +0700 Subject: [PATCH] feat(up): use Apple container DNS when domain registered, /etc/hosts fallback otherwise When the project's sanitized name is already registered via container system dns create, name containers . and pass --dns-domain so the daemon serves DNS for peers. The /etc/hosts cross-patcher is skipped on this path. Otherwise keep the legacy dashed names + patcher unchanged, with a notice pointing at the sudo command. ComposeDown now tries every candidate name shape (legacy, dotted, explicit) so it cleans up containers from either mode. Static tests cover sanitizeDnsDomain and dnsListContainsDomain. Dynamic test verifies dotted naming, peer resolution via getent, and that /etc/hosts is left untouched when DNS is available. --- .../Commands/ComposeDown.swift | 51 +++--- .../Commands/ComposeUp.swift | 171 ++++++++++++++++-- .../ComposeUpDnsTests.swift | 135 ++++++++++++++ .../DnsDomainTests.swift | 92 ++++++++++ 4 files changed, 411 insertions(+), 38 deletions(-) create mode 100644 Tests/Container-Compose-DynamicTests/ComposeUpDnsTests.swift create mode 100644 Tests/Container-Compose-StaticTests/DnsDomainTests.swift diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 6770d11..965288b 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -119,38 +119,41 @@ public struct ComposeDown: AsyncParsableCommand { private func stopOldStuff(_ services: [(serviceName: String, service: Service)], remove: Bool) async throws { guard let projectName else { return } + // The DNS path uses a dotted name `.`. Compute the + // domain the same way ComposeUp does so we can stop containers from + // either mode (and from a previous run that used a different mode). + let dnsDomain = ComposeUp.sanitizeDnsDomain(projectName) for (serviceName, service) in services { - // Respect explicit container_name, otherwise use default pattern - let containerName: String - if let explicitContainerName = service.container_name { - containerName = explicitContainerName - } else { - containerName = "\(projectName)-\(serviceName)" + var candidates: [String] = ["\(projectName)-\(serviceName)"] + if let dnsDomain { candidates.append("\(serviceName).\(dnsDomain)") } + if let explicit = service.container_name, !candidates.contains(explicit) { + candidates.append(explicit) } - print("Stopping container: \(containerName)") - let client = ContainerClient() - - guard let container = try? await client.get(id: containerName) else { - print("Warning: Container '\(containerName)' not found, skipping.") - continue - } - - do { - try await client.stop(id: container.id) - print("Successfully stopped container: \(containerName)") - } catch { - print("Error Stopping Container: \(error)") - } - if remove { + var stoppedAny = false + for name in candidates { + guard let container = try? await client.get(id: name) else { continue } + stoppedAny = true + print("Stopping container: \(name)") do { - try await client.delete(id: container.id) - print("Successfully removed container: \(containerName)") + try await client.stop(id: container.id) + print("Successfully stopped container: \(name)") } catch { - print("Error Removing Container: \(error)") + print("Error Stopping Container: \(error)") } + if remove { + do { + try await client.delete(id: container.id) + print("Successfully removed container: \(name)") + } catch { + print("Error Removing Container: \(error)") + } + } + } + if !stoppedAny { + print("Warning: No container found for service '\(serviceName)' (tried: \(candidates.joined(separator: ", "))).") } } } diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index ebb6360..e367d00 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -100,8 +100,21 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private var fileManager: FileManager { FileManager.default } private var projectName: String? + /// Apple `container` DNS domain to use for inter-container resolution. Derived + /// from `projectName` (sanitized to a valid DNS label). `nil` if the project + /// name produces no usable label. + private var dnsDomain: String? + /// True when `dnsDomain` is registered with `container system dns create`, + /// which means the daemon's embedded DNS server will answer for `*.` + /// queries from inside containers. When true, services get a dotted `--name` + /// + `--dns-domain` and the /etc/hosts cross-patcher is skipped. + private var dnsAvailable: Bool = false private var environmentVariables: [String: String] = [:] private var containerIps: [String: String] = [:] + /// Resolved container ID (i.e. the name on disk) per service. + /// Equal to `service.container_name` when set, otherwise either + /// `.` (DNS path) or `-` (legacy). + private var serviceContainerNames: [String: String] = [:] private var containerConsoleColors: [String: NamedColor] = [:] private static let availableContainerConsoleColors: Set = [ @@ -142,6 +155,24 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print("Info: No 'name' field found in docker-compose.yml. Using directory name as project name: \(projectName ?? "")") } + // Determine whether real DNS is available for this project. If so, we'll + // give every container a dotted name (`.`) and pass + // `--dns-domain` so libc inside the container resolves peers via the + // daemon's DNS server. If not, fall back to /etc/hosts patching. + if let derived = Self.sanitizeDnsDomain(projectName ?? "") { + dnsDomain = derived + dnsAvailable = await checkDnsDomainRegistered(derived) + if dnsAvailable { + print("Info: DNS domain '\(derived)' is registered. Using real DNS for inter-container resolution.") + } else { + print(""" + Note: DNS domain '\(derived)' is not registered. Inter-container hostname + resolution will fall back to /etc/hosts patching. For real DNS: + sudo container system dns create \(derived) + """) + } + } + // Get Services to use var services: [(serviceName: String, service: Service)] = dockerCompose.services.compactMap({ serviceName, service in guard let service else { return nil } @@ -156,8 +187,17 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { }) } - // Stop Services - try await stopOldStuff(services.map({ $0.serviceName }), remove: true) + // Stop Services. Pass every name a previous run might have used (legacy + // dashed, dotted DNS-mode, and explicit container_name) so the cleanup + // catches whichever shape exists on disk. + let containerNamesToStop: [String] = services.flatMap { (serviceName, service) -> [String] in + var names: [String] = [] + if let projectName { names.append("\(projectName)-\(serviceName)") } + if let dnsDomain { names.append("\(serviceName).\(dnsDomain)") } + if let explicit = service.container_name, !names.contains(explicit) { names.append(explicit) } + return names + } + try await stopExistingContainers(containerNamesToStop, remove: true) // Process top-level networks // This creates named networks defined in the docker-compose.yml @@ -247,14 +287,70 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return (entrypointFlag, positional) } - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - guard let projectName else { return nil } + private func containerName(for serviceName: String) -> String { + if let explicit = serviceContainerNames[serviceName] { return explicit } + if let projectName { return "\(projectName)-\(serviceName)" } + return serviceName + } + + /// Coerce an arbitrary project name into a single DNS label: lowercase, only + /// `[a-z0-9-]`, no leading/trailing/repeated hyphens, max 63 chars. Returns + /// `nil` when nothing usable remains (e.g. a name made entirely of separators). + static func sanitizeDnsDomain(_ name: String) -> String? { + let allowed: Set = Set("abcdefghijklmnopqrstuvwxyz0123456789-") + var out = "" + for ch in name.lowercased() { + out.append(allowed.contains(ch) ? ch : "-") + } + while out.contains("--") { + out = out.replacingOccurrences(of: "--", with: "-") + } + while out.hasPrefix("-") { out.removeFirst() } + while out.hasSuffix("-") { out.removeLast() } + if out.count > 63 { + out = String(out.prefix(63)) + while out.hasSuffix("-") { out.removeLast() } + } + return out.isEmpty ? nil : out + } + + /// Pure parser for `container system dns list` output. Output looks like: + /// DOMAIN + /// foo + /// bar + /// Each non-header line is a registered domain; header is `DOMAIN`. + static func dnsListContainsDomain(_ output: String, domain: String) -> Bool { + for raw in output.split(separator: "\n", omittingEmptySubsequences: true) { + let line = raw.trimmingCharacters(in: .whitespaces) + if line.isEmpty || line == "DOMAIN" { continue } + if line == domain { return true } + } + return false + } + + /// Checks whether `domain` has been registered via `container system dns create`. + /// Returns `false` if the CLI is missing, the call fails, or the domain isn't listed. + private func checkDnsDomainRegistered(_ domain: String) async -> Bool { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "dns", "list"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return false } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return false } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + return Self.dnsListContainsDomain(text, domain: domain) + } - let containerName = "\(projectName)-\(serviceName)" + private func getIPForRunningService(_ serviceName: String) async throws -> String? { + let name = containerName(for: serviceName) let client = ContainerClient() - let container = try await client.get(id: containerName) - let ip = container.networks.compactMap { $0.ipv4Gateway.description }.first + let container = try await client.get(id: name) + let ip = container.networks.compactMap { $0.ipv4Address.address.description }.first return ip } @@ -266,8 +362,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - interval: How often to poll (in seconds). /// - Returns: `true` if the container reached "running" state within the timeout. private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { - guard let projectName else { return } - let containerName = "\(projectName)-\(serviceName)" + let containerName = containerName(for: serviceName) let deadline = Date().addingTimeInterval(timeout) let client = ContainerClient() @@ -287,11 +382,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ]) } - private func stopOldStuff(_ services: [String], remove: Bool) async throws { - guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - - for container in containers { + /// Stops (and optionally removes) containers matching the given names. + /// Accepts pre-computed name strings so callers can pass all candidate + /// shapes (legacy dashed, dotted DNS, explicit `container_name`) and + /// teardown works regardless of which mode created them. + private func stopExistingContainers(_ names: [String], remove: Bool) async throws { + for container in names { print("Stopping container: \(container)") let client = ContainerClient() guard let container = try? await client.get(id: container) else { continue } @@ -319,6 +415,40 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { self.environmentVariables[key] = ip ?? value } + if !dnsAvailable { + await crossPatchHostsForService(serviceName) + } + } + + /// Apple `container` does not provide built-in DNS resolution between containers + /// on the same network. As each service comes up, mutate /etc/hosts in every + /// already-running peer to add ` `, and also add all the + /// previously-known peers into the new container. This is best-effort — services + /// that need DNS at startup time should still wait/retry. + private func crossPatchHostsForService(_ newServiceName: String) async { + guard let newIP = containerIps[newServiceName] else { return } + let newContainerID = containerName(for: newServiceName) + // Add the new entry in every previously-running peer. + for (peerName, peerIP) in containerIps where peerName != newServiceName { + let peerContainerID = containerName(for: peerName) + await appendHostsEntry(in: peerContainerID, name: newServiceName, ip: newIP) + // Also make the new container aware of this peer, in case it queries it later. + await appendHostsEntry(in: newContainerID, name: peerName, ip: peerIP) + } + } + + private func appendHostsEntry(in containerID: String, name: String, ip: String) async { + // Idempotent: skip if the line is already present. Use the `container` CLI + // because the streaming exec API is not exposed here. + let line = "\(ip) \(name)" + let cmd = "grep -qF '\(line)' /etc/hosts 2>/dev/null || echo '\(line)' >> /etc/hosts" + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "exec", containerID, "sh", "-c", cmd] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { try process.run() } catch { return } + process.waitUntilExit() } private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async { @@ -440,13 +570,26 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let explicitContainerName = service.container_name { containerName = explicitContainerName print("Info: Using explicit container_name: \(containerName)") + } else if dnsAvailable, let dnsDomain { + // Apple's DNS convention: the container's resolvable name is the + // `--name` itself, e.g. `db.` (see apple/container #800). + containerName = "\(serviceName).\(dnsDomain)" } else { // Default container name based on project and service name containerName = "\(projectName)-\(serviceName)" } + serviceContainerNames[serviceName] = containerName runCommandArgs.append("--name") runCommandArgs.append(containerName) + // When real DNS is available, point the container at the project's DNS + // domain. The daemon writes `nameserver ` + `domain ` + // into /etc/resolv.conf, so libc resolves both `db.` and the + // short `db` (via implicit search list) to the peer's address. + if dnsAvailable, let dnsDomain { + runCommandArgs.append(contentsOf: ["--dns-domain", dnsDomain]) + } + // REMOVED: Restart policy is not supported by `container run` // if let restart = service.restart { // runCommandArgs.append("--restart") diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpDnsTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpDnsTests.swift new file mode 100644 index 0000000..8857130 --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/ComposeUpDnsTests.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 +import Foundation +import ContainerCommands +import ContainerAPIClient +import TestHelpers +@testable import ContainerComposeCore + +/// Tests for the DNS-aware path in `ComposeUp`. These require a DNS domain to be +/// pre-registered with `sudo container system dns create `. The test +/// uses `dnstest` as a conventional fixed domain name; if it isn't registered, +/// the test logs an instruction and returns rather than failing. +@Suite("Compose Up Tests - Real DNS path", .containerDependent, .serialized) +struct ComposeUpDnsTests { + + private static let testDomain = "dnstest" + + func stopInstance(location: URL) async throws { + var composeDown = try ComposeDown.parse(["--cwd", location.path(percentEncoded: false)]) + try await composeDown.run() + } + + /// Mirrors `ComposeUp.checkDnsDomainRegistered` without making it public — + /// shells out and parses with the same helper used by the production code. + private func dnsDomainRegistered(_ domain: String) -> Bool { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "dns", "list"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return false } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return false } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8) ?? "" + return ComposeUp.dnsListContainsDomain(text, domain: domain) + } + + /// Run `container exec` and return stdout. Returns nil on failure. + private func containerExec(_ id: String, _ args: [String]) -> String? { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "exec", id] + args + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + do { try process.run() } catch { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = stdout.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) + } + + @Test("With DNS available, services use dotted names and skip /etc/hosts patching") + func dnsPathUsesDottedNamesAndSkipsHostsPatch() async throws { + guard dnsDomainRegistered(Self.testDomain) else { + print(""" + Skipping: requires '\(Self.testDomain)' to be registered. + Enable with: sudo container system dns create \(Self.testDomain) + """) + return + } + + let yaml = """ + name: \(Self.testDomain) + services: + db: + image: alpine:latest + command: ["sleep", "120"] + app: + image: alpine:latest + command: ["sleep", "120"] + depends_on: [db] + """ + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + try await composeUp.run() + + let appID = "app.\(Self.testDomain)" + let dbID = "db.\(Self.testDomain)" + + // Containers must exist under their dotted IDs. + let client = ContainerClient() + let appContainer = try? await client.get(id: appID) + let dbContainer = try? await client.get(id: dbID) + #expect(appContainer != nil, "expected container '\(appID)' to exist") + #expect(dbContainer != nil, "expected container '\(dbID)' to exist") + + // /etc/hosts in the app container must NOT contain a cross-patched + // entry for `db` — DNS path skips `crossPatchHostsForService`. + let hosts = containerExec(appID, ["cat", "/etc/hosts"]) ?? "" + #expect(!hosts.contains(" db\n") && !hosts.hasSuffix(" db"), + "/etc/hosts unexpectedly contains a 'db' entry — cross-patcher should be skipped on DNS path. Contents:\n\(hosts)") + + // resolv.conf inside the container should carry the project's DNS domain. + let resolv = containerExec(appID, ["cat", "/etc/resolv.conf"]) ?? "" + #expect(resolv.contains("domain \(Self.testDomain)"), + "/etc/resolv.conf missing 'domain \(Self.testDomain)':\n\(resolv)") + + // Real DNS resolution: short and dotted names both work. + let shortLookup = containerExec(appID, ["getent", "hosts", "db"]) ?? "" + let dottedLookup = containerExec(appID, ["getent", "hosts", dbID]) ?? "" + #expect(shortLookup.contains(dbID), "short-name 'db' did not resolve to peer (got: \(shortLookup))") + #expect(dottedLookup.contains(dbID), "dotted name '\(dbID)' did not resolve (got: \(dottedLookup))") + + try? await stopInstance(location: project.base) + // Best-effort hard cleanup of stopped containers (ComposeDown stops but + // doesn't remove, matching docker compose down semantics). + _ = containerExec(appID, []) // no-op if not running + let delete = Process() + delete.launchPath = "/usr/bin/env" + delete.arguments = ["container", "delete", "-f", appID, dbID] + delete.standardOutput = Pipe() + delete.standardError = Pipe() + try? delete.run() + delete.waitUntilExit() + } +} diff --git a/Tests/Container-Compose-StaticTests/DnsDomainTests.swift b/Tests/Container-Compose-StaticTests/DnsDomainTests.swift new file mode 100644 index 0000000..77ad152 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/DnsDomainTests.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// 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 +import Foundation +@testable import ContainerComposeCore + +@Suite("DNS Domain Helpers") +struct DnsDomainTests { + + @Test("sanitize - already valid label is unchanged") + func sanitizeIdentity() { + #expect(ComposeUp.sanitizeDnsDomain("dnstest") == "dnstest") + #expect(ComposeUp.sanitizeDnsDomain("my-app-1") == "my-app-1") + } + + @Test("sanitize - lowercases mixed case") + func sanitizeLowercases() { + #expect(ComposeUp.sanitizeDnsDomain("Container-Compose") == "container-compose") + } + + @Test("sanitize - replaces underscores, dots, spaces with hyphens") + func sanitizeReplacesSeparators() { + #expect(ComposeUp.sanitizeDnsDomain("my_app") == "my-app") + #expect(ComposeUp.sanitizeDnsDomain("my.app") == "my-app") + #expect(ComposeUp.sanitizeDnsDomain("my app") == "my-app") + } + + @Test("sanitize - collapses runs of separators") + func sanitizeCollapsesRuns() { + #expect(ComposeUp.sanitizeDnsDomain("a__b..c d") == "a-b-c-d") + } + + @Test("sanitize - trims leading and trailing hyphens") + func sanitizeTrims() { + #expect(ComposeUp.sanitizeDnsDomain("--foo--") == "foo") + #expect(ComposeUp.sanitizeDnsDomain(".devcontainers") == "devcontainers") + } + + @Test("sanitize - returns nil for unusable input") + func sanitizeReturnsNil() { + #expect(ComposeUp.sanitizeDnsDomain("") == nil) + #expect(ComposeUp.sanitizeDnsDomain("___") == nil) + #expect(ComposeUp.sanitizeDnsDomain("...") == nil) + } + + @Test("sanitize - clamps to 63 chars and re-trims") + func sanitizeClampsLength() { + let long = String(repeating: "a", count: 70) + "-" + let out = ComposeUp.sanitizeDnsDomain(long) + #expect(out?.count == 63) + #expect(out?.last != "-") + } + + @Test("dns list - empty output means not registered") + func dnsListEmpty() { + #expect(ComposeUp.dnsListContainsDomain("", domain: "anything") == false) + } + + @Test("dns list - header-only output (no domains)") + func dnsListHeaderOnly() { + #expect(ComposeUp.dnsListContainsDomain("DOMAIN\n", domain: "anything") == false) + } + + @Test("dns list - matches a registered domain") + func dnsListMatches() { + let output = "DOMAIN\ndnstest\nfoo\n" + #expect(ComposeUp.dnsListContainsDomain(output, domain: "dnstest") == true) + #expect(ComposeUp.dnsListContainsDomain(output, domain: "foo") == true) + #expect(ComposeUp.dnsListContainsDomain(output, domain: "bar") == false) + } + + @Test("dns list - exact match only (no substring)") + func dnsListExactMatch() { + let output = "DOMAIN\ndnstest\n" + #expect(ComposeUp.dnsListContainsDomain(output, domain: "test") == false) + #expect(ComposeUp.dnsListContainsDomain(output, domain: "dns") == false) + } +}