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) + } +}