diff --git a/Sources/Container-Compose/Codable Structs/DependencyConfig.swift b/Sources/Container-Compose/Codable Structs/DependencyConfig.swift new file mode 100644 index 0000000..96dc247 --- /dev/null +++ b/Sources/Container-Compose/Codable Structs/DependencyConfig.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// +// DependencyConfig.swift +// Container-Compose +// +// Represents dependency configuration for depends_on with conditions +// + +/// Condition types for service dependencies +public enum DependsOnCondition: String, Codable, Hashable { + case service_started + case service_healthy + case service_completed_successfully +} + +/// Configuration for a service dependency +public struct DependencyConfig: Codable, Hashable { + /// Condition that must be met before dependent service starts + public let condition: DependsOnCondition + /// Whether to restart the dependent service if this service restarts + public let restart: Bool? + /// Whether this dependency is required + public let required: Bool? + + public init( + condition: DependsOnCondition = .service_started, + restart: Bool? = nil, + required: Bool? = nil + ) { + self.condition = condition + self.restart = restart + self.required = required + } +} diff --git a/Sources/Container-Compose/Codable Structs/Service.swift b/Sources/Container-Compose/Codable Structs/Service.swift index 82bfa36..25d2032 100644 --- a/Sources/Container-Compose/Codable Structs/Service.swift +++ b/Sources/Container-Compose/Codable Structs/Service.swift @@ -56,8 +56,8 @@ public struct Service: Codable, Hashable { /// Command to execute in the container, overriding the image's default public let command: [String]? - /// Services this service depends on (for startup order) - public let depends_on: [String]? + /// Services this service depends on with optional conditions (for startup order) + public let depends_on: [String: DependencyConfig]? /// User or UID to run the container as public let user: String? @@ -119,7 +119,7 @@ public struct Service: Codable, Hashable { env_file: [String]? = nil, ports: [String]? = nil, command: [String]? = nil, - depends_on: [String]? = nil, + depends_on: [String: DependencyConfig]? = nil, user: String? = nil, container_name: String? = nil, networks: [String]? = nil, @@ -190,10 +190,27 @@ public struct Service: Codable, Hashable { command = nil } - if let dependsOnString = try? container.decodeIfPresent(String.self, forKey: .depends_on) { - depends_on = [dependsOnString] + // Decode 'depends_on' which can be: + // 1. A simple array: ["mysql", "redis"] + // 2. An extended dictionary with conditions: { mysql: { condition: service_healthy }, redis: { condition: service_started } } + // 3. A dictionary without conditions (null values): { mysql: null, redis: null } or { mysql:, redis: } + if let dependsOnDict = try? container.decodeIfPresent([String: DependencyConfig].self, forKey: .depends_on) { + depends_on = dependsOnDict + } else if let dependsOnDictOptional = try? container.decodeIfPresent([String: DependencyConfig?].self, forKey: .depends_on) { + // Convert dictionary with optional values (null values) to dictionary with default condition + depends_on = Dictionary(uniqueKeysWithValues: dependsOnDictOptional.map { key, value in + (key, value ?? DependencyConfig(condition: .service_started)) + }) + } else if let dependsOnArray = try? container.decodeIfPresent([String].self, forKey: .depends_on) { + // Convert simple array to dictionary with default condition + depends_on = Dictionary(uniqueKeysWithValues: dependsOnArray.map { + ($0, DependencyConfig(condition: .service_started)) + }) + } else if let dependsOnString = try? container.decodeIfPresent(String.self, forKey: .depends_on) { + // Handle single string + depends_on = [dependsOnString: DependencyConfig(condition: .service_started)] } else { - depends_on = try container.decodeIfPresent([String].self, forKey: .depends_on) + depends_on = nil } user = try container.decodeIfPresent(String.self, forKey: .user) @@ -243,8 +260,10 @@ public struct Service: Codable, Hashable { guard !visited.contains(name) else { return } visiting.insert(name) - for depName in serviceTuple.service.depends_on ?? [] { - try visit(depName, from: name) + if let depends = serviceTuple.service.depends_on { + for depName in depends.keys { + try visit(depName, from: name) + } } visiting.remove(name) visited.insert(name) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 126d637..2da9ad0 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -165,6 +165,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { print(services.map(\.serviceName)) for (serviceName, service) in services { + // Wait for dependencies to meet their conditions before starting this service + if let dependencies = service.depends_on { + for (depServiceName, depConfig) in dependencies { + let condition = depConfig.condition + print("Waiting for service '\(depServiceName)' to meet condition '\(condition.rawValue)' before starting '\(serviceName)'...") + try await waitUntilServiceMeetsCondition(depServiceName, condition: condition, timeout: 120) + } + } + try await configService(service, serviceName: serviceName, from: dockerCompose) } @@ -191,13 +200,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { return ip } - /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// Waits for a service to meet the specified condition based on depends_on configuration. /// - Parameters: - /// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db"). + /// - serviceName: The service name + /// - condition: The condition to wait for (service_started, service_healthy, etc.) /// - timeout: Max seconds to wait before failing. /// - 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 { + private func waitUntilServiceMeetsCondition(_ serviceName: String, condition: DependsOnCondition, timeout: TimeInterval = 60, interval: TimeInterval = 0.5) async throws { guard let projectName else { return } let containerName = "\(projectName)-\(serviceName)" @@ -205,8 +214,32 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { while Date() < deadline { let container = try? await ClientContainer.get(id: containerName) - if container?.status == .running { - return + + switch condition { + case .service_started: + // Just wait for container to be running + if container?.status == .running { + return + } + + case .service_healthy: + // Wait for container to be running AND healthy + // TODO: Implement proper health check monitoring once Container API exposes health status + // For now, wait for container to be running and add a delay for health checks + if container?.status == .running { + print("Warning: Health check monitoring not yet fully implemented. Waiting for container to be running plus grace period...") + // Give some time for health checks to pass (simplified for now) + try await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds + return + } + + case .service_completed_successfully: + // Wait for container to complete (not currently running) + // TODO: Check exit code once API exposes it + if let container = container, container.status != .running { + print("Warning: Container '\(containerName)' has stopped. Exit code checking not yet implemented.") + return + } } try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) @@ -215,10 +248,19 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { throw NSError( domain: "ContainerWait", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to be running." + NSLocalizedDescriptionKey: "Timed out waiting for container '\(containerName)' to meet condition '\(condition.rawValue)'." ]) } + /// Repeatedly checks `container list -a` until the given container is listed as `running`. + /// - Parameters: + /// - serviceName: The service name + /// - timeout: Max seconds to wait before failing. + /// - interval: How often to poll (in seconds). + private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws { + try await waitUntilServiceMeetsCondition(serviceName, condition: .service_started, timeout: timeout, interval: interval) + } + private func stopOldStuff(_ services: [String], remove: Bool) async throws { guard let projectName else { return } let containers = services.map { "\(projectName)-\($0)" }