Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 27 additions & 24 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svc>.<dnsDomain>`. 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: ", "))).")
}
}
}
Expand Down
171 changes: 157 additions & 14 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `*.<dnsDomain>`
/// 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
/// `<serviceName>.<dnsDomain>` (DNS path) or `<projectName>-<serviceName>` (legacy).
private var serviceContainerNames: [String: String] = [:]
private var containerConsoleColors: [String: NamedColor] = [:]

private static let availableContainerConsoleColors: Set<NamedColor> = [
Expand Down Expand Up @@ -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 (`<svc>.<dnsDomain>`) 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 }
Expand All @@ -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
Expand Down Expand Up @@ -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<Character> = 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
}
Expand All @@ -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()
Expand All @@ -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 }
Expand Down Expand Up @@ -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 `<thisIP> <thisService>`, 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 {
Expand Down Expand Up @@ -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.<project>` (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 <gateway>` + `domain <dnsDomain>`
// into /etc/resolv.conf, so libc resolves both `db.<dnsDomain>` 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")
Expand Down
Loading
Loading