diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index b0950ef..5921109 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -472,6 +472,29 @@ public struct Service: Codable, Hashable { return waves } + /// Names of services that some other service declares + /// `condition: service_completed_successfully` on. + /// + /// A one-shot only passes *through* `.running` on its way to `.stopped`, so + /// a readiness wait that returns on the first `.running` it observes records + /// the wrong terminal state and the dependent is refused. Which services must + /// be waited out is not visible on the service itself — it lives on whoever + /// depends on it, which is what this collects. + static func servicesRequiredToComplete( + _ services: [(serviceName: String, service: Service)] + ) -> Set { + var required: Set = [] + for (_, service) in services { + for dependencyName in service.depends_on ?? [] { + let condition = service.dependencyConditions?[dependencyName] ?? ServiceDependency() + if condition.effectiveCondition == ServiceDependency.serviceCompletedSuccessfully { + required.insert(dependencyName) + } + } + } + return required + } + /// Validates that every explicitly requested service name exists among the /// services defined in the compose file. Mirrors `docker compose up `, /// which fails with "no such service: " rather than silently selecting diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d42efd7..7a0d7e6 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -72,6 +72,71 @@ private actor ForegroundRunHandle { func exitCodeIfCompleted() -> Int32? { exitCode } } +/// Owns every piece of cross-service mutable state touched while launching +/// services. `up` used to keep these as `ComposeUp` (a value-type command) +/// dictionaries and mutate them from a strictly serial loop; launching a whole +/// dependency wave concurrently (issue #128) would race those dictionaries, and +/// `@unchecked Sendable` hides that from the compiler. Routing all reads/writes +/// through this actor makes concurrent launches data-race-free. Within a wave +/// services are independent (they only depend on earlier, already-completed +/// waves), so this actor is the only synchronization needed. +private actor LaunchState { + var environmentVariables: [String: String] + private var containerIps: [String: String] = [:] + private var serviceStartStates: [String: ServiceStartState] = [:] + private var serviceHealth: [String: Bool] = [:] + private var serviceContainerNames: [String: String] = [:] + private var usedColors: Set = [] + private let palette: [NamedColor] + + init(environmentVariables: [String: String], palette: [NamedColor]) { + self.environmentVariables = environmentVariables + self.palette = palette + } + + // Container names + func recordContainerName(_ name: String, for service: String) { serviceContainerNames[service] = name } + func allContainerNames() -> [String: String] { serviceContainerNames } + + // Start state + func recordStartState(_ state: ServiceStartState, for service: String) { serviceStartStates[service] = state } + func startState(of service: String) -> ServiceStartState? { serviceStartStates[service] } + + // Health + func recordHealthy(_ service: String) { serviceHealth[service] = true } + func isHealthy(_ service: String) -> Bool { serviceHealth[service] == true } + + // IPs + env (IP substitution mirrors the old updateEnvironmentWithServiceIP) + func recordIP(_ ip: String?, for service: String) { + containerIps[service] = ip + for (key, value) in environmentVariables where value == service { + environmentVariables[key] = ip ?? value + } + } + func allContainerIps() -> [String: String] { containerIps } + func environmentSnapshot() -> [String: String] { environmentVariables } + + /// Atomically picks a console color, preferring one not yet used while the + /// palette isn't exhausted (was `ComposeUp`'s racy read-modify-write of + /// `containerConsoleColors`). + func pickColor(for service: String) -> NamedColor { + var color = palette.randomElement()! + if usedColors.count < palette.count { + while usedColors.contains(color) { color = palette.randomElement()! } + } + usedColors.insert(color) + return color + } +} + +/// Carries the immutable compose model (a value type) into a concurrent launch +/// task. Each task only reads it, so sending it across the isolation boundary is +/// safe — the same reason the command structs are `@unchecked Sendable`. +private struct UncheckedSendable: @unchecked Sendable { + let value: T + init(_ value: T) { self.value = value } +} + public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { public init() {} @@ -103,6 +168,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { @Flag(name: .long, help: "Do not use cache") var noCache: Bool = false + @Flag( + name: .customLong("sequential"), + help: "Launch services one at a time in dependency order (the pre-parallel behavior). By default, services with no pending dependency start concurrently.") + var sequential: Bool = false + @OptionGroup var logging: Flags.Logging @@ -119,15 +189,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// 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 + /// Seed environment loaded from the `.env` file. Copied into `LaunchState` + /// (which owns the live, IP-substituted copy) once launching begins. private var environmentVariables: [String: String] = [:] - private var containerIps: [String: String] = [:] - private var serviceStartStates: [String: ServiceStartState] = [:] - private var serviceHealth: [String: Bool] = [:] - /// 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 = [ .blue, .cyan, .magenta, .lightBlack, .lightBlue, .lightCyan, .lightYellow, .yellow, .lightGreen, .green, @@ -214,14 +278,69 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Process each service defined in the docker-compose.yml print("\n--- Processing Services ---") - print(project.services.map(\.serviceName)) - for target in project.services { - try await configService(target.service, serviceName: target.serviceName, from: dockerCompose) + + // All cross-service launch state lives in this actor so a wave of + // services can be launched concurrently without racing. + let launchState = LaunchState( + environmentVariables: environmentVariables, + palette: Array(Self.availableContainerConsoleColors)) + + // `run()` is `mutating` (it set projectName/dnsDomain/... above), so an + // escaping task closure can't capture `self`. Snapshot the now-immutable + // command config into a `let` the concurrent tasks can share. + let launcher = self + + // Which services a dependent waits out rather than merely starts. Needed + // by both paths: the sequential one has the same one-shot race, it just + // loses it far less often. + let mustComplete = Service.servicesRequiredToComplete( + project.services.map { ($0.serviceName, $0.service) }) + + if sequential { + // Explicit opt-out (`--sequential`): the original one-at-a-time + // behavior, in topological order. + for target in project.services { + try await configService( + target.service, serviceName: target.serviceName, + from: dockerCompose, launchState: launchState, + awaitCompletion: mustComplete.contains(target.serviceName)) + } + } else { + // Launch each dependency WAVE concurrently. `dependencyLevels` groups + // services so every service in a wave has all of its in-set + // dependencies in an earlier wave; a dependency cycle throws here, + // before anything is launched. The barrier between waves means a + // service's dependencies are always fully started before it begins. + let servicesByName = Dictionary( + uniqueKeysWithValues: project.services.map { ($0.serviceName, $0.service) }) + let waves = try Service.dependencyLevels( + project.services.map { ($0.serviceName, $0.service) }) + let composeBox = UncheckedSendable(dockerCompose) + for wave in waves { + try await withThrowingTaskGroup(of: Void.self) { group in + for serviceName in wave { + guard let service = servicesByName[serviceName] else { continue } + let inputs = UncheckedSendable((service: service, name: serviceName)) + let awaitCompletion = mustComplete.contains(serviceName) + group.addTask { + try await launcher.configService( + inputs.value.service, serviceName: inputs.value.name, + from: composeBox.value, launchState: launchState, + awaitCompletion: awaitCompletion) + } + } + try await group.waitForAll() + } + } } if !detach { - await runForegroundUntilStopped(containerNames: project.services.map({ containerName(for: $0.serviceName) })) + let names = await launchState.allContainerNames() + let containerNames = project.services.map { + names[$0.serviceName] ?? "\(projectName)-\($0.serviceName)" + } + await runForegroundUntilStopped(containerNames: containerNames) } } @@ -492,9 +611,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func containerName(for serviceName: String) -> String { - serviceContainerNames[serviceName] ?? "\(projectName)-\(serviceName)" - } /// Pure parser for `container system dns list` output. Output looks like: /// DOMAIN @@ -527,9 +643,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return Self.dnsListContainsDomain(text, domain: domain) } - private func getIPForRunningService(_ serviceName: String) async throws -> String? { - let name = containerName(for: serviceName) - + private func getIPForRunningService(containerName name: String) async throws -> String? { let client = ContainerClient() let container = try await client.get(id: name) // Use the container's own address, not the network gateway — every @@ -565,23 +679,69 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// - idleTimeout: Max seconds of no output (while not running) before failing. /// - maxWait: Absolute ceiling on the wait, regardless of ongoing output. /// - interval: How often to poll (in seconds). + /// What the wait loop should do next. + enum WaitVerdict: Equatable { + case keepWaiting + case idleTimeout + case totalTimeout + } + + /// Separates the two waits that share one loop. + /// + /// Before the container is running, silence is the signal that matters: an + /// image pull streams progress, so a gap with nothing coming out and nothing + /// running means genuinely stuck rather than merely slow. That is what + /// `idleTimeout` is for. + /// + /// Once it has been seen running, startup is over and the loop is waiting for + /// a one-shot's work to finish. Silence there is the normal case — a `chown -R` + /// over a large volume prints nothing for minutes — so the idle timeout no + /// longer applies. `maxWait` still bounds the whole thing, because an + /// unbounded wait is the worse failure. + static func waitVerdict( + hasStarted: Bool, + silentFor: TimeInterval, + elapsed: TimeInterval, + idleTimeout: TimeInterval, + maxWait: TimeInterval + ) -> WaitVerdict { + if !hasStarted, silentFor > idleTimeout { return .idleTimeout } + if elapsed > maxWait { return .totalTimeout } + return .keepWaiting + } + private func waitUntilServiceStarted( _ serviceName: String, + containerName: String, activity: ActivityClock, foregroundRun: ForegroundRunHandle? = nil, + awaitCompletion: Bool = false, idleTimeout: TimeInterval = 30, maxWait: TimeInterval = 300, interval: TimeInterval = 0.5 ) async throws -> ServiceStartState { - let containerName = containerName(for: serviceName) let client = ContainerClient() let start = Date() + var hasStarted = false while true { try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) let container = try? await client.get(id: containerName) + // A one-shot passes through `.running`; returning here would record + // that as its terminal state and refuse every dependent waiting for + // `service_completed_successfully`. Keep polling for `.stopped` + // instead. Launching a whole wave concurrently made this near + // certain — with six VMs booting at once a `chown -R` init is still + // running at the first 0.5s poll, where launched one at a time it + // had almost always already exited. if container?.status == .running { - return .running + if !awaitCompletion { + return .running + } + // Startup is over. What follows is a different wait — for the + // one-shot's work to finish — and it must not be judged by the + // startup budget. + hasStarted = true } if container?.status == .stopped { if let foregroundRun { @@ -612,23 +772,29 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } let now = Date() - // An active pull keeps refreshing `activity`, pushing the idle - // deadline out, so slow downloads never trip this — only genuine - // silence does. - if now.timeIntervalSince(activity.lastActivity) > idleTimeout { + switch Self.waitVerdict( + hasStarted: hasStarted, + silentFor: now.timeIntervalSince(activity.lastActivity), + elapsed: now.timeIntervalSince(start), + idleTimeout: idleTimeout, + maxWait: maxWait + ) { + case .keepWaiting: + break + case .idleTimeout: throw NSError( domain: "ContainerWait", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." ]) - } - // Absolute backstop: even with continuous output, never wait past - // `maxWait` for the container to come up. - if now.timeIntervalSince(start) > maxWait { + case .totalTimeout: + let what = hasStarted + ? "to finish (it started, then ran for over \(Int(maxWait))s)" + : "to be running (exceeded \(Int(maxWait))s)" throw NSError( domain: "ContainerWait", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running (exceeded \(Int(maxWait))s)." + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' \(what)." ]) } } @@ -641,13 +807,26 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { ]) } - private static func waitForInitExitCode(containerName: String) async throws -> Int32 { + /// How long a one-shot is allowed to run before its completion wait gives up. + /// + /// `containerWait` answers when the container exits, so this response timeout + /// is the one-shot's whole runtime budget, not a request budget. At ten + /// seconds any init doing real work — a `chown -R` over a populated volume, + /// a schema load — failed with an XPC timeout that named neither the service + /// nor what it had been waiting for. It matches the wait loop's `maxWait` so + /// there is one number bounding "how long may a one-shot take". + static let oneShotCompletionTimeout: TimeInterval = 300 + + private static func waitForInitExitCode( + containerName: String, + timeout: TimeInterval = ComposeUp.oneShotCompletionTimeout + ) async throws -> Int32 { let request = XPCMessage(route: .containerWait) request.set(key: .id, value: containerName) request.set(key: .processIdentifier, value: containerName) let client = XPCClient(service: "com.apple.container.apiserver") - let response = try await client.send(request, responseTimeout: .seconds(10)) + let response = try await client.send(request, responseTimeout: .seconds(Int(timeout))) return Int32(response.int64(key: .exitCode)) } @@ -678,14 +857,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // MARK: Compose Top Level Functions - private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws { - let ip = try await getIPForRunningService(serviceName) - self.containerIps[serviceName] = ip - for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName { - self.environmentVariables[key] = ip ?? value - } + private func updateEnvironmentWithServiceIP(_ serviceName: String, containerName: String, launchState: LaunchState) async throws { + let ip = try await getIPForRunningService(containerName: containerName) + await launchState.recordIP(ip, for: serviceName) if !dnsAvailable { - await crossPatchHostsForService(serviceName) + await crossPatchHostsForService(serviceName, launchState: launchState) } } @@ -694,12 +870,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { /// 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) + private func crossPatchHostsForService(_ newServiceName: String, launchState: LaunchState) async { + let ips = await launchState.allContainerIps() + let names = await launchState.allContainerNames() + guard let newIP = ips[newServiceName] else { return } + let newContainerID = names[newServiceName] ?? "\(projectName)-\(newServiceName)" // Add the new entry in every previously-running peer. - for (peerName, peerIP) in containerIps where peerName != newServiceName { - let peerContainerID = containerName(for: peerName) + for (peerName, peerIP) in ips where peerName != newServiceName { + let peerContainerID = names[peerName] ?? "\(projectName)-\(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) @@ -809,9 +987,52 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } + /// The name the daemon registers a container under when a DNS domain is + /// configured: the container name with the domain appended, unless it + /// already carries it (the no-`container_name` path builds a dotted name + /// itself). Matches the `hostname` `container inspect` reports. + static func dnsRegistrationName(containerName: String, dnsDomain: String) -> String { + containerName.hasSuffix(".\(dnsDomain)") ? containerName : "\(containerName).\(dnsDomain)" + } + + /// Blocks until the daemon has published a DNS record for `name`, or the + /// budget runs out. + /// + /// A container reaching `.running` does not mean its name resolves yet: + /// Apple Container publishes the record a moment afterwards. Docker's + /// embedded DNS updates as the container joins the network, so `depends_on` + /// there implies resolvability and every compose file relies on it — + /// haproxy resolves all of its `server` lines while parsing its config and + /// aborts if one fails, which is how a proxy in the next wave died on + /// "could not resolve address 'patroni2'" with patroni2 up and healthy. + /// + /// Only advisory: on timeout it warns and proceeds, because a compose file + /// may legitimately name a container the daemon never registers. + private func waitUntilNameResolves( + _ name: String, timeout: TimeInterval = 20, interval: TimeInterval = 0.25 + ) async { + let start = Date() + while Date().timeIntervalSince(start) < timeout { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + var result: UnsafeMutablePointer? + let rc = getaddrinfo(name, nil, &hints, &result) + if let result { freeaddrinfo(result) } + if rc == 0 { return } + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + print( + "Warning: '\(name)' still does not resolve after \(Int(timeout))s; " + + "a service that resolves its peers at startup may fail.") + } + // MARK: Compose Service Level Functions - private mutating func configService(_ service: Service, serviceName: String, from dockerCompose: DockerCompose) async throws { - try waitForDependencyConditions(serviceName: serviceName, service: service) + private func configService( + _ service: Service, serviceName: String, from dockerCompose: DockerCompose, + launchState: LaunchState, awaitCompletion: Bool = false + ) async throws { + try await waitForDependencyConditions(serviceName: serviceName, service: service, launchState: launchState) var imageToRun: String @@ -862,7 +1083,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Default container name based on project and service name containerName = "\(projectName)-\(serviceName)" } - serviceContainerNames[serviceName] = containerName + await launchState.recordContainerName(containerName, for: serviceName) runCommandArgs.append("--name") runCommandArgs.append(containerName) @@ -907,8 +1128,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - // Combine environment variables from .env files and service environment - var combinedEnv: [String: String] = environmentVariables + // Combine environment variables from .env files and service environment. + // The base env is snapshotted from the actor so it includes the IP + // substitutions recorded by services in earlier (already-completed) waves. + let envSnapshot = await launchState.environmentSnapshot() + var combinedEnv: [String: String] = envSnapshot if let envFiles = service.env_file { for envFile in envFiles { @@ -934,9 +1158,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return combinedEnv[variableName] ?? value }) - // Fill in IPs + // Fill in IPs (peer container addresses recorded by earlier waves). + let ipSnapshot = await launchState.allContainerIps() combinedEnv = combinedEnv.mapValues({ value in - containerIps[value] ?? value + ipSnapshot[value] ?? value }) // MARK: Spinning Spot @@ -948,7 +1173,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { if let ports = service.ports { for port in ports { - let resolvedPort = resolveVariable(port, with: environmentVariables) + let resolvedPort = resolveVariable(port, with: envSnapshot) runCommandArgs.append("-p") runCommandArgs.append(composePortToRunArg(resolvedPort)) } @@ -957,7 +1182,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Connect to specified networks if let serviceNetworks = service.networks { for network in serviceNetworks { - let resolvedNetwork = resolveVariable(network, with: environmentVariables) + let resolvedNetwork = resolveVariable(network, with: envSnapshot) // Use the explicit network name from top-level definition if available, otherwise resolved name let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork runCommandArgs.append("--network") @@ -1135,16 +1360,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { runCommandArgs.append(imageToRun) runCommandArgs.append(contentsOf: argv.positional) - var serviceColor: NamedColor = Self.availableContainerConsoleColors.randomElement()! - - if Array(Set(containerConsoleColors.values)).sorted(by: { $0.rawValue < $1.rawValue }) != Self.availableContainerConsoleColors.sorted(by: { $0.rawValue < $1.rawValue }) { - while containerConsoleColors.values.contains(serviceColor) { - serviceColor = Self.availableContainerConsoleColors.randomElement()! - } - } - - let selectedColor = serviceColor - self.containerConsoleColors[serviceName] = selectedColor + let selectedColor = await launchState.pickColor(for: serviceName) // Tracks output from the run subprocess so the readiness wait below can // tell an in-progress image pull from a stuck container. @@ -1205,15 +1421,23 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - let startState = try await waitUntilServiceStarted(serviceName, activity: activity, foregroundRun: foregroundRunHandle) - serviceStartStates[serviceName] = startState + let startState = try await waitUntilServiceStarted( + serviceName, containerName: containerName, activity: activity, + foregroundRun: foregroundRunHandle, awaitCompletion: awaitCompletion) + await launchState.recordStartState(startState, for: serviceName) switch startState { case .running: - try await updateEnvironmentWithServiceIP(serviceName) + try await updateEnvironmentWithServiceIP(serviceName, containerName: containerName, launchState: launchState) + // Make the wave barrier a DNS barrier too: the next wave's services + // resolve this one's name while parsing their own config. + if dnsAvailable, let dnsDomain { + await waitUntilNameResolves( + Self.dnsRegistrationName(containerName: containerName, dnsDomain: dnsDomain)) + } if let healthcheck = service.healthcheck, !healthcheck.isDisabled { - try await waitUntilServiceIsHealthy(serviceName: serviceName, healthcheck: healthcheck) - serviceHealth[serviceName] = true + try await waitUntilServiceIsHealthy(serviceName: serviceName, containerName: containerName, healthcheck: healthcheck) + await launchState.recordHealthy(serviceName) } case .completed: if let healthcheck = service.healthcheck, !healthcheck.isDisabled { @@ -1222,20 +1446,24 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func waitForDependencyConditions(serviceName: String, service: Service) throws { + /// Asserts that each of `service`'s dependencies has already reached its + /// required condition. Under both the serial path and the wave-parallel + /// path a service's dependencies are in an earlier wave that has fully + /// completed before this runs, so these are satisfied checks, not polls. + private func waitForDependencyConditions(serviceName: String, service: Service, launchState: LaunchState) async throws { for dependencyName in service.depends_on ?? [] { let dependency = service.dependencyConditions?[dependencyName] ?? ServiceDependency() switch dependency.effectiveCondition { case ServiceDependency.serviceStarted: - guard serviceStartStates[dependencyName] != nil else { + guard await launchState.startState(of: dependencyName) != nil else { throw ComposeError.dependencyNotStarted(serviceName, dependencyName) } case ServiceDependency.serviceHealthy: - guard serviceHealth[dependencyName] == true else { + guard await launchState.isHealthy(dependencyName) else { throw ComposeError.dependencyNotHealthy(serviceName, dependencyName) } case ServiceDependency.serviceCompletedSuccessfully: - guard serviceStartStates[dependencyName] == .completed else { + guard await launchState.startState(of: dependencyName) == .completed else { throw ComposeError.dependencyNotCompleted(serviceName, dependencyName) } default: @@ -1244,12 +1472,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } } - private func waitUntilServiceIsHealthy(serviceName: String, healthcheck: Healthcheck) async throws { + private func waitUntilServiceIsHealthy(serviceName: String, containerName: String, healthcheck: Healthcheck) async throws { guard let execArguments = healthcheck.execArguments else { return } - let containerName = containerName(for: serviceName) let retries = max(healthcheck.retries ?? 3, 1) let interval = Healthcheck.parseDuration(healthcheck.interval, default: 30) let startPeriod = Healthcheck.parseDuration(healthcheck.start_period, default: 0) diff --git a/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift b/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift new file mode 100644 index 0000000..0216f02 --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/DependentResolutionTests.swift @@ -0,0 +1,145 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// What the wave barrier exists to guarantee, asserted where it matters. +/// +/// The barrier resolves the name with `getaddrinfo` in the compose process, which +/// goes through the host's resolver. A dependent resolves through its own +/// `/etc/resolv.conf` against the daemon's DNS from inside the container network — +/// a different path, so a host lookup succeeding does not establish that the +/// dependent can resolve the name. This starts a real dependency pair and asks the +/// dependent. +/// +/// The project is named after a domain that is actually registered, discovered +/// rather than hardcoded, because `up` derives its DNS domain from the project +/// name: with a name that is not a registered domain the DNS path is never taken +/// and there is nothing to assert. +@Suite("Dependent Resolution", .containerDependent, .serialized) +struct DependentResolutionTests { + + /// First domain from `container system dns list`, or nil when none is + /// registered — in which case the DNS path cannot engage at all. + private static func registeredDomain() -> String? { + let process = Process() + process.launchPath = "/usr/bin/env" + process.arguments = ["container", "system", "dns", "list"] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + guard (try? process.run()) != nil else { return nil } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let text = String( + data: stdout.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return text.split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .first { !$0.isEmpty && $0 != "DOMAIN" } + } + + private final class Output: @unchecked Sendable { + private let lock = NSLock() + private var text = "" + func append(_ s: String) { lock.lock(); text += s; lock.unlock() } + var value: String { lock.lock(); defer { lock.unlock() }; return text } + } + + private func exec(_ container: String, _ args: [String]) async throws -> String { + let output = Output() + _ = try await ComposeUp.parse(["-d", "--cwd", NSTemporaryDirectory()]) + .streamCommand( + "container", args: ["exec", container] + args, + onStdout: { output.append($0) }, onStderr: { _ in }) + return output.value + } + + @Test("a dependent resolves its dependency's name from inside the container") + func dependentResolvesDependency() async throws { + guard let domain = Self.registeredDomain() else { + print(""" + Skipping: no DNS domain is registered, so `up` never takes the DNS path. + Register one with: sudo container system dns create + """) + return + } + + let dir = URL.temporaryDirectory.appending(path: "depres-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let yaml = """ + name: \(domain) + services: + upstream: + image: alpine:latest + command: ["sleep", "120"] + dependent: + image: alpine:latest + command: ["sleep", "120"] + depends_on: [upstream] + """ + try yaml.write(to: dir.appending(path: "docker-compose.yaml"), atomically: false, encoding: .utf8) + // On the DNS path containers are named `.`, not + // `-`. Getting this wrong makes every exec target a + // container that does not exist, and `getent` returning nothing then looks + // exactly like a resolution failure — which is how this test first "failed". + let upstream = "upstream.\(domain)" + let dependent = "dependent.\(domain)" + + func tearDown() async { + var down = try? ComposeDown.parse(["--cwd", dir.path(percentEncoded: false)]) + try? await down?.run() + for name in [upstream, dependent] { + _ = try? await ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + .streamCommand("container", args: ["delete", "-f", name], + onStdout: { _ in }, onStderr: { _ in }) + } + try? FileManager.default.removeItem(at: dir) + } + + var composeUp = try ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + do { + try await composeUp.run() + + // Anti-vacuity: an exec against a container that does not exist also + // returns nothing, so prove the target is there before reading a miss + // as a resolution failure. + let selfCheck = try await exec(dependent, ["echo", "alive"]) + #expect(selfCheck.contains("alive"), + "could not exec into '\(dependent)' — the assertions below would be vacuous") + + // Resolution goes through the container's own resolver, which is a + // different path from the `getaddrinfo` the barrier runs on the host. + let resolv = try await exec(dependent, ["cat", "/etc/resolv.conf"]) + #expect(resolv.contains("domain \(domain)"), + "dependent's resolv.conf does not carry the domain: \(resolv)") + + // The claim the barrier exists to make: by the time the dependent is + // up, it can resolve the service it depends on. + let short = try await exec(dependent, ["getent", "hosts", "upstream"]) + let dotted = try await exec(dependent, ["getent", "hosts", upstream]) + #expect(short.contains("upstream"), + "dependent could not resolve short name 'upstream' (got: \(short))") + #expect(dotted.contains("upstream"), + "dependent could not resolve '\(upstream)' (got: \(dotted))") + } catch { + await tearDown() + throw error + } + await tearDown() + } +} diff --git a/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift b/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift new file mode 100644 index 0000000..b51bd5d --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/SilentOneShotTests.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A one-shot that does its work without printing anything. +/// +/// Waiting for a container to start and waiting for a one-shot to finish share a +/// loop, and the idle timeout only answers the first question. A `chown -R` over a +/// large volume prints nothing for minutes; judged by the startup budget it was +/// failed as "timed out waiting for it to be running" after it had already been +/// seen running. +@Suite("Silent One-Shot", .containerDependent, .serialized) +struct SilentOneShotTests { + + /// Comfortably past the idle timeout the wait uses while starting, and + /// comfortably inside the total budget. + private static let silentSeconds = 45 + + @Test("a one-shot that runs silently past the idle timeout still completes") + func silentOneShotCompletes() async throws { + let project = "silent-oneshot-\(UUID().uuidString.prefix(8).lowercased())" + let dir = URL.temporaryDirectory.appending(path: project) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let yaml = """ + name: \(project) + services: + quiet: + image: alpine:latest + restart: "no" + command: ["sleep", "\(Self.silentSeconds)"] + after: + image: alpine:latest + command: ["sleep", "5"] + depends_on: + quiet: + condition: service_completed_successfully + """ + try yaml.write(to: dir.appending(path: "docker-compose.yaml"), atomically: false, encoding: .utf8) + + func tearDown() async { + var down = try? ComposeDown.parse(["--cwd", dir.path(percentEncoded: false)]) + try? await down?.run() + for name in ["\(project)-quiet", "\(project)-after"] { + _ = try? await ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + .streamCommand("container", args: ["delete", "-f", name], + onStdout: { _ in }, onStderr: { _ in }) + } + try? FileManager.default.removeItem(at: dir) + } + + var composeUp = try ComposeUp.parse(["-d", "--cwd", dir.path(percentEncoded: false)]) + let start = ContinuousClock.now + do { + // Before the fix this threw at ~30s of silence, with a message saying + // the container had timed out waiting to be running — by which point + // it had been running for most of that time. + try await composeUp.run() + } catch { + await tearDown() + throw error + } + let elapsed = ContinuousClock.now - start + await tearDown() + + // It had to outlast the idle timeout to prove anything. + #expect(elapsed > .seconds(Self.silentSeconds - 10), + "up returned in \(elapsed), too fast to have waited out the one-shot") + } +} diff --git a/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift b/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift new file mode 100644 index 0000000..19fd5b8 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/DNSRegistrationNameTests.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A container reaching `.running` does not mean its name resolves yet — Apple +/// Container publishes the DNS record a moment afterwards, where Docker's +/// embedded DNS updates as the container joins the network. `up` therefore waits +/// for the record before releasing the wave, and this is the name it waits on. +@Suite("DNS Registration Name") +struct DNSRegistrationNameTests { + + @Test("the domain is appended to a plain container name") + func plainNameGetsDomain() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "patroni2", dnsDomain: "dcf") + == "patroni2.dcf") + } + + /// Without `container_name`, `up` already builds a dotted name itself; + /// appending again would wait on `patroni2.dcf.dcf`, which never resolves. + @Test("an already-qualified name is left alone") + func qualifiedNameUnchanged() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "patroni2.dcf", dnsDomain: "dcf") + == "patroni2.dcf") + } + + @Test("a name ending in the domain's letters but not the domain is still qualified") + func similarSuffixStillQualified() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "mydcf", dnsDomain: "dcf") + == "mydcf.dcf") + } + + @Test("a multi-label domain works the same way") + func multiLabelDomain() { + #expect( + ComposeUp.dnsRegistrationName(containerName: "db", dnsDomain: "test.local") + == "db.test.local") + } +} diff --git a/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift b/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift new file mode 100644 index 0000000..7cbc55e --- /dev/null +++ b/Tests/Container-Compose-StaticTests/RequiredToCompleteTests.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// 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 Yams +@testable import ContainerComposeCore + +/// A one-shot service only passes *through* `.running`. The readiness wait +/// returned on the first `.running` it saw, so a fast init that had not quite +/// exited yet was recorded as running, and every dependent declaring +/// `service_completed_successfully` was refused: +/// +/// Service 'etcd2' depends on 'etcd-init' with condition +/// 'service_completed_successfully', but 'etcd-init' has not completed +/// successfully. +/// +/// Whether a service must be waited out is not readable from the service — it +/// lives on whoever depends on it. +@Suite("Services Required To Complete") +struct RequiredToCompleteTests { + + private func targets(_ yaml: String) throws -> [(serviceName: String, service: Service)] { + let doc = try YAMLDecoder().decode(DockerCompose.self, from: yaml) + return doc.services.compactMap { name, service in + service.map { (serviceName: name, service: $0) } + } + } + + @Test("a service depended on for completion is collected") + func completionDependencyCollected() throws { + let t = try targets(""" + services: + init: + image: alpine + app: + image: nginx + depends_on: + init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["init"]) + } + + @Test("service_started and service_healthy do not require completion") + func otherConditionsExcluded() throws { + let t = try targets(""" + services: + db: + image: postgres + cache: + image: redis + app: + image: nginx + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + /// `depends_on` in list form carries no condition, and the default is + /// `service_started` — so it must not be collected. + @Test("the list form of depends_on defaults to service_started") + func listFormDefaultsToStarted() throws { + let t = try targets(""" + services: + db: + image: postgres + app: + image: nginx + depends_on: + - db + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + /// dcf-local-env's shape: three peers each waiting on the same init. + @Test("one init depended on by several services is collected once") + func sharedInitCollectedOnce() throws { + let t = try targets(""" + services: + etcd-init: + image: alpine + etcd1: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + etcd2: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + etcd3: + image: etcd + depends_on: + etcd-init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["etcd-init"]) + } + + @Test("a service nobody depends on requires nothing") + func noDependenciesAtAll() throws { + let t = try targets(""" + services: + solo: + image: nginx + """) + #expect(Service.servicesRequiredToComplete(t).isEmpty) + } + + @Test("two distinct one-shots are both collected") + func twoOneShots() throws { + let t = try targets(""" + services: + a-init: + image: alpine + b-init: + image: alpine + app: + image: nginx + depends_on: + a-init: + condition: service_completed_successfully + b-init: + condition: service_completed_successfully + """) + #expect(Service.servicesRequiredToComplete(t) == ["a-init", "b-init"]) + } +} diff --git a/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift b/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift new file mode 100644 index 0000000..e13db42 --- /dev/null +++ b/Tests/Container-Compose-StaticTests/WaitVerdictTests.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Waiting for a container to start and waiting for a one-shot to finish are two +/// different questions sharing one loop, and the idle timeout only answers the +/// first. A one-shot that works quietly — `chown -R` over a large volume prints +/// nothing — used to be failed by the startup budget after it had already been +/// seen running, and told the operator it had timed out "waiting for it to be +/// running". +@Suite("Wait Verdict") +struct WaitVerdictTests { + + private func verdict( + started: Bool, silentFor: TimeInterval, elapsed: TimeInterval, + idle: TimeInterval = 30, max: TimeInterval = 300 + ) -> ComposeUp.WaitVerdict { + ComposeUp.waitVerdict( + hasStarted: started, silentFor: silentFor, elapsed: elapsed, + idleTimeout: idle, maxWait: max) + } + + /// The regression: silent well past the idle timeout, but already running. + @Test("a silent one-shot that has started is not failed by the idle timeout") + func silentAfterStartingKeepsWaiting() { + #expect(verdict(started: true, silentFor: 120, elapsed: 130) == .keepWaiting) + } + + /// The behaviour the idle timeout exists for, unchanged. + @Test("silence before starting still trips the idle timeout") + func silentBeforeStartingTimesOut() { + #expect(verdict(started: false, silentFor: 31, elapsed: 31) == .idleTimeout) + } + + /// An active pull keeps refreshing activity, so a slow download is not silence. + @Test("a noisy pull that has not started yet keeps waiting") + func noisyBeforeStartingKeepsWaiting() { + #expect(verdict(started: false, silentFor: 2, elapsed: 200) == .keepWaiting) + } + + /// The backstop still applies once running: unbounded is the worse failure. + @Test("the total budget still bounds a one-shot that has started") + func totalBudgetStillApplies() { + #expect(verdict(started: true, silentFor: 400, elapsed: 301) == .totalTimeout) + } + + @Test("the total budget bounds a noisy container that never starts") + func totalBudgetBoundsNoisyStartup() { + #expect(verdict(started: false, silentFor: 1, elapsed: 301) == .totalTimeout) + } + + /// Exactly at each threshold is still waiting; the comparisons are strict. + @Test("the thresholds are exclusive") + func thresholdsAreExclusive() { + #expect(verdict(started: false, silentFor: 30, elapsed: 30) == .keepWaiting) + #expect(verdict(started: true, silentFor: 500, elapsed: 300) == .keepWaiting) + } + + /// Idle is reported ahead of total when both are past, because it is the more + /// specific diagnosis: nothing came out and nothing started. + @Test("idle wins over total before the container starts") + func idlePreferredBeforeStart() { + #expect(verdict(started: false, silentFor: 400, elapsed: 400) == .idleTimeout) + } +}