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
2 changes: 1 addition & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ let package = Package(
name: "ContainerCommands",
package: "container"
),
.product(
name: "ContainerXPC",
package: "container"
),
.product(
name: "ArgumentParser",
package: "swift-argument-parser"
Expand Down
60 changes: 60 additions & 0 deletions Sources/Container-Compose/Codable Structs/Healthcheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,21 @@
// Created by Morris Richman on 6/17/25.
//

import Foundation

/// Healthcheck configuration for a service.
public struct Healthcheck: Codable, Hashable {
private static let durationUnits: [String: TimeInterval] = [
"ns": 0.000000001,
"us": 0.000001,
"µs": 0.000001,
"ms": 0.001,
"s": 1,
"m": 60,
"h": 3600,
]
private static let durationRegex = try! NSRegularExpression(pattern: #"([0-9]+(?:\.[0-9]+)?)(ns|us|µs|ms|s|m|h)"#)

/// Command to run to check health
public let test: [String]?
/// Grace period for the container to start
Expand Down Expand Up @@ -66,4 +78,52 @@ public struct Healthcheck: Codable, Hashable {
self.retries = try container.decodeIfPresent(Int.self, forKey: .retries)
self.timeout = try container.decodeIfPresent(String.self, forKey: .timeout)
}

public var isDisabled: Bool {
test?.first?.uppercased() == "NONE"
}

public var execArguments: [String]? {
guard let test, !test.isEmpty, !isDisabled else {
return nil
}

switch test[0].uppercased() {
case "CMD":
let command = Array(test.dropFirst())
return command.isEmpty ? nil : command
case "CMD-SHELL":
let command = test.dropFirst().joined(separator: " ")
return command.isEmpty ? nil : ["sh", "-c", command]
default:
return test
}
}

public static func parseDuration(_ value: String?, default defaultValue: TimeInterval) -> TimeInterval {
guard let value, !value.isEmpty else {
return defaultValue
}

if let seconds = TimeInterval(value) {
return seconds
}

let range = NSRange(value.startIndex..<value.endIndex, in: value)
let matches = Self.durationRegex.matches(in: value, range: range)
guard !matches.isEmpty else {
return defaultValue
}

return matches.reduce(0) { total, match in
guard
let amountRange = Range(match.range(at: 1), in: value),
let unitRange = Range(match.range(at: 2), in: value),
let amount = TimeInterval(value[amountRange])
else {
return total
}
return total + amount * (Self.durationUnits[String(value[unitRange])] ?? 0)
}
}
}
33 changes: 28 additions & 5 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public struct Service: Codable, Hashable {
/// Services this service depends on (for startup order)
public let depends_on: [String]?

/// Service dependency options keyed by dependency service name.
public let dependencyConditions: [String: ServiceDependency]?

/// User or UID to run the container as
public let user: String?

Expand All @@ -74,6 +77,9 @@ public struct Service: Codable, Hashable {
/// List of networks the service will connect to
public let networks: [String]?

/// Service network options keyed by network name.
public let networkConfigurations: [String: ServiceNetwork]?

/// Container hostname
public let hostname: String?

Expand Down Expand Up @@ -126,10 +132,12 @@ public struct Service: Codable, Hashable {
ports: [String]? = nil,
command: [String]? = nil,
depends_on: [String]? = nil,
dependencyConditions: [String: ServiceDependency]? = nil,
user: String? = nil,
container_name: String? = nil,
labels: [String: String]? = nil,
networks: [String]? = nil,
networkConfigurations: [String: ServiceNetwork]? = nil,
hostname: String? = nil,
entrypoint: [String]? = nil,
privileged: Bool? = nil,
Expand All @@ -153,10 +161,12 @@ public struct Service: Codable, Hashable {
self.ports = ports
self.command = command
self.depends_on = depends_on
self.dependencyConditions = dependencyConditions
self.user = user
self.container_name = container_name
self.labels = labels
self.networks = networks
self.networkConfigurations = networkConfigurations
self.hostname = hostname
self.entrypoint = entrypoint
self.privileged = privileged
Expand Down Expand Up @@ -222,20 +232,33 @@ public struct Service: Codable, Hashable {

if let dependsOnString = try? container.decodeIfPresent(String.self, forKey: .depends_on) {
depends_on = [dependsOnString]
dependencyConditions = [dependsOnString: ServiceDependency()]
} else if let dependsOnArray = try? container.decodeIfPresent([String].self, forKey: .depends_on) {
depends_on = dependsOnArray
} else if let dependsOnMap = try? container.decodeIfPresent([String: [String: String]].self, forKey: .depends_on) {
// Map form: depends_on: { db: { condition: service_healthy } }
// Preserve dependency order; conditions are not applicable to Apple Container.
depends_on = dependsOnMap.keys.sorted()
dependencyConditions = Dictionary(uniqueKeysWithValues: dependsOnArray.map { ($0, ServiceDependency()) })
} else if let dependsOnMap = try? container.decodeIfPresent([String: ServiceDependency?].self, forKey: .depends_on) {
let normalized = dependsOnMap.mapValues { $0 ?? ServiceDependency() }
depends_on = normalized.keys.sorted()
dependencyConditions = normalized
} else {
depends_on = nil
dependencyConditions = nil
}
user = try container.decodeIfPresent(String.self, forKey: .user)

container_name = try container.decodeIfPresent(String.self, forKey: .container_name)
labels = try container.decodeIfPresent([String: String].self, forKey: .labels)
networks = try container.decodeIfPresent([String].self, forKey: .networks)
if let networkArray = try? container.decodeIfPresent([String].self, forKey: .networks) {
networks = networkArray
networkConfigurations = Dictionary(uniqueKeysWithValues: networkArray.map { ($0, ServiceNetwork()) })
} else if let networkMap = try? container.decodeIfPresent([String: ServiceNetwork?].self, forKey: .networks) {
let normalized = networkMap.mapValues { $0 ?? ServiceNetwork() }
networks = normalized.keys.sorted()
networkConfigurations = normalized
} else {
networks = nil
networkConfigurations = nil
}
hostname = try container.decodeIfPresent(String.self, forKey: .hostname)

// Decode 'entrypoint' which can be either a single string or an array of strings.
Expand Down
45 changes: 45 additions & 0 deletions Sources/Container-Compose/Codable Structs/ServiceDependency.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//

/// Service-level `depends_on` options for Compose map-form dependencies.
public struct ServiceDependency: Codable, Hashable {
public static let serviceStarted = "service_started"
public static let serviceHealthy = "service_healthy"
public static let serviceCompletedSuccessfully = "service_completed_successfully"

/// Dependency condition, for example `service_started` or `service_healthy`.
public let condition: String?

/// Compose optional restart hint for dependency updates.
public let restart: Bool?

/// Compose optional required hint. Defaults to true in Docker Compose.
public let required: Bool?

public var effectiveCondition: String {
condition ?? Self.serviceStarted
}

public init(
condition: String? = nil,
restart: Bool? = nil,
required: Bool? = nil
) {
self.condition = condition
self.restart = restart
self.required = required
}
}
37 changes: 37 additions & 0 deletions Sources/Container-Compose/Codable Structs/ServiceNetwork.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//

/// Service-specific network options for Compose object-form networks.
public struct ServiceNetwork: Codable, Hashable {
/// Additional DNS aliases requested for this service on the network.
public let aliases: [String]?

/// Static IPv4 address requested by the Compose file.
public let ipv4_address: String?

/// Static IPv6 address requested by the Compose file.
public let ipv6_address: String?

public init(
aliases: [String]? = nil,
ipv4_address: String? = nil,
ipv6_address: String? = nil
) {
self.aliases = aliases
self.ipv4_address = ipv4_address
self.ipv6_address = ipv6_address
}
}
Loading