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
49 changes: 49 additions & 0 deletions Sources/Container-Compose/Codable Structs/DependencyConfig.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
35 changes: 27 additions & 8 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
56 changes: 49 additions & 7 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -191,22 +200,46 @@ 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)"

let deadline = Date().addingTimeInterval(timeout)

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))
Expand All @@ -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)" }
Expand Down