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
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ let package = Package(
name: "ContainerXPC",
package: "container"
),
.product(
name: "ContainerResource",
package: "container"
),
.product(
name: "ArgumentParser",
package: "swift-argument-parser"
Expand Down
14 changes: 13 additions & 1 deletion Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public struct Service: Codable, Hashable {
/// Restart policy (e.g., 'unless-stopped', 'always')
public let restart: String?

/// Time Compose waits for the container to stop before sending SIGKILL.
public let stop_grace_period: StopGracePeriod?

/// Healthcheck configuration
public let healthcheck: Healthcheck?

Expand Down Expand Up @@ -131,7 +134,7 @@ public struct Service: Codable, Hashable {

// Defines custom coding keys to map YAML keys to Swift properties
enum CodingKeys: String, CodingKey {
case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user,
case image, build, deploy, restart, stop_grace_period, healthcheck, volumes, environment, env_file, ports, command, depends_on, user,
container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform,
mem_limit, extra_hosts, profiles
}
Expand All @@ -142,6 +145,7 @@ public struct Service: Codable, Hashable {
build: Build? = nil,
deploy: Deploy? = nil,
restart: String? = nil,
stop_grace_period: StopGracePeriod? = nil,
healthcheck: Healthcheck? = nil,
volumes: [String]? = nil,
environment: [String: String]? = nil,
Expand Down Expand Up @@ -174,6 +178,7 @@ public struct Service: Codable, Hashable {
self.build = build
self.deploy = deploy
self.restart = restart
self.stop_grace_period = stop_grace_period
self.healthcheck = healthcheck
self.volumes = volumes
self.environment = environment
Expand Down Expand Up @@ -216,6 +221,7 @@ public struct Service: Codable, Hashable {
}

restart = try container.decodeIfPresent(String.self, forKey: .restart)
stop_grace_period = try container.decodeIfPresent(StopGracePeriod.self, forKey: .stop_grace_period)
healthcheck = try container.decodeIfPresent(Healthcheck.self, forKey: .healthcheck)
volumes = try container.decodeIfPresent([String].self, forKey: .volumes)

Expand Down Expand Up @@ -357,6 +363,12 @@ public struct Service: Codable, Hashable {
guard let profiles, !profiles.isEmpty else { return true }
return !Set(profiles).isDisjoint(with: activeProfiles)
}

/// The stop timeout passed to Apple Container, using Compose's 10-second
/// default when the service does not specify `stop_grace_period`.
public var stopTimeoutInSeconds: Int32 {
stop_grace_period?.timeoutInSeconds ?? StopGracePeriod.defaultTimeoutInSeconds
}

/// Translates the list-form of `environment:` into the same `[String: String]`
/// shape produced by the map form. Handles two cases:
Expand Down
124 changes: 124 additions & 0 deletions Sources/Container-Compose/Codable Structs/StopGracePeriod.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//===----------------------------------------------------------------------===//
// 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 Foundation

/// A Compose duration used for the time allowed for a container to stop.
public struct StopGracePeriod: Codable, Hashable, Sendable {
/// Docker Compose's default when `stop_grace_period` is omitted.
public static let defaultTimeoutInSeconds: Int32 = 10

/// The duration as written in the Compose file.
public let value: String

/// The whole seconds accepted by Apple Container's stop API.
///
/// Docker Compose also passes the duration to a whole-second runtime API,
/// so fractional seconds are truncated rather than rounded up.
public let timeoutInSeconds: Int32

public init(_ value: String) throws {
let seconds = try Self.parseSeconds(value)
self.value = value
self.timeoutInSeconds = Int32(seconds)
}

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let value = try container.decode(String.self)
try self.init(value)
}

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}

private static func parseSeconds(_ value: String) throws -> Double {
guard !value.isEmpty else { throw InvalidDurationError(value: value) }

var index = value.startIndex
var total: Double = 0
var foundComponent = false

while index < value.endIndex {
let numberStart = index
while index < value.endIndex, value[index].isNumber {
index = value.index(after: index)
}
guard index > numberStart else { throw InvalidDurationError(value: value) }

if index < value.endIndex, value[index] == "." {
index = value.index(after: index)
let fractionStart = index
while index < value.endIndex, value[index].isNumber {
index = value.index(after: index)
}
guard index > fractionStart else { throw InvalidDurationError(value: value) }
}

let number = String(value[numberStart..<index])
guard let amount = Double(number), amount.isFinite else {
throw InvalidDurationError(value: value)
}

let unit: String
if value[index...].hasPrefix("us") || value[index...].hasPrefix("µs") {
unit = String(value[index...].prefix(2))
index = value.index(index, offsetBy: 2)
} else if value[index...].hasPrefix("ms") {
unit = "ms"
index = value.index(index, offsetBy: 2)
} else if value[index...].hasPrefix("s") {
unit = "s"
index = value.index(after: index)
} else if value[index...].hasPrefix("m") {
unit = "m"
index = value.index(after: index)
} else if value[index...].hasPrefix("h") {
unit = "h"
index = value.index(after: index)
} else {
throw InvalidDurationError(value: value)
}

let multiplier: Double = switch unit {
case "us", "µs": 0.000001
case "ms": 0.001
case "s": 1
case "m": 60
case "h": 3600
default: 0
}
total += amount * multiplier
guard total.isFinite else { throw InvalidDurationError(value: value) }
foundComponent = true
}

guard foundComponent, total >= 0, total <= Double(Int32.max) else {
throw InvalidDurationError(value: value)
}
return total.rounded(.towardZero)
}

private struct InvalidDurationError: LocalizedError {
let value: String

var errorDescription: String? {
"Invalid stop_grace_period duration: '\(value)'"
}
}
}
5 changes: 4 additions & 1 deletion Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ public struct ComposeDown: AsyncParsableCommand {
stoppedAny = true
print("Stopping container: \(name)")
do {
try await client.stop(id: container.id)
try await client.stop(
id: container.id,
opts: ComposeStopOptions.resolve(for: target.service)
)
print("Successfully stopped container: \(name)")
} catch {
print("Error Stopping Container: \(error)")
Expand Down
80 changes: 50 additions & 30 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ private actor ForegroundRunHandle {
func exitCodeIfCompleted() -> Int32? { exitCode }
}

struct ComposeContainerStopTarget: Sendable {
let containerName: String
let timeoutInSeconds: Int32
}

public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
public init() {}

Expand Down Expand Up @@ -189,7 +194,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// 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.
try await stopExistingContainers(project.services.flatMap(\.candidateContainerNames), remove: true)
try await stopExistingContainers(project.services, remove: true)

// Process top-level networks
// This creates named networks defined in the docker-compose.yml
Expand Down Expand Up @@ -221,7 +226,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

if !detach {
await runForegroundUntilStopped(containerNames: project.services.map({ containerName(for: $0.serviceName) }))
let stopTargets = project.services.map {
ComposeContainerStopTarget(
containerName: containerName(for: $0.serviceName),
timeoutInSeconds: $0.service.stopTimeoutInSeconds
)
}
await runForegroundUntilStopped(containerTargets: stopTargets)
}
}

Expand All @@ -231,10 +242,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
/// - If the containers stop on their own — or via `container compose down`
/// from another shell — `up` returns instead of hanging forever.
///
/// Takes resolved container names (not service names): a service's container
/// may be named via explicit `container_name` or the dotted
/// `<service>.<dnsDomain>` DNS convention, not just `<project>-<service>`.
func runForegroundUntilStopped(containerNames: [String]) async -> Never {
/// Takes resolved container names paired with their Compose stop timeouts:
/// a service's container may be named via explicit `container_name` or the
/// dotted `<service>.<dnsDomain>` DNS convention, not just `<project>-<service>`.
func runForegroundUntilStopped(containerTargets: [ComposeContainerStopTarget]) async -> Never {
let containerNames = containerTargets.map(\.containerName)

// Exit once the containers stop by themselves or are stopped externally.
if !containerNames.isEmpty {
Task {
Expand All @@ -256,7 +269,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
stopping = true
print("\nGracefully stopping... (press Ctrl+C again to force)")
Task {
await Self.stopContainers(containerNames)
await Self.stopContainers(containerTargets)
Foundation.exit(0)
}
} else {
Expand Down Expand Up @@ -289,15 +302,18 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

/// Gracefully stops (without removing) the named containers — the
/// `docker compose up` Ctrl-C contract leaves stopped containers in place.
private static func stopContainers(_ containerNames: [String]) async {
private static func stopContainers(_ containerTargets: [ComposeContainerStopTarget]) async {
let client = ContainerClient()
for name in containerNames {
guard let container = try? await client.get(id: name) else { continue }
print("Stopping container: \(name)")
for target in containerTargets {
guard let container = try? await client.get(id: target.containerName) else { continue }
print("Stopping container: \(target.containerName)")
do {
try await client.stop(id: container.id)
try await client.stop(
id: container.id,
opts: ComposeStopOptions.resolve(timeoutInSeconds: target.timeoutInSeconds)
)
} catch {
print("Error stopping container \(name): \(error)")
print("Error stopping container \(target.containerName): \(error)")
}
}
}
Expand Down Expand Up @@ -651,26 +667,30 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
return Int32(response.int64(key: .exitCode))
}

/// 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 }
/// Stops (and optionally removes) containers matching each service's
/// candidate names. This covers legacy dashed, dotted DNS, and explicit
/// `container_name` shapes from previous runs.
private func stopExistingContainers(_ targets: [ComposeProject.ServiceTarget], remove: Bool) async throws {
for target in targets {
for name in target.candidateContainerNames {
print("Stopping container: \(name)")
let client = ContainerClient()
guard let container = try? await client.get(id: name) else { continue }

do {
try await client.stop(id: container.id)
} catch {
print("Error Stopping Container: \(error)")
}
if remove {
do {
try await client.delete(id: container.id)
try await client.stop(
id: container.id,
opts: ComposeStopOptions.resolve(for: target.service)
)
} catch {
print("Error Removing Container: \(error)")
print("Error Stopping Container: \(error)")
}
if remove {
do {
try await client.delete(id: container.id)
} catch {
print("Error Removing Container: \(error)")
}
}
}
}
Expand Down
34 changes: 34 additions & 0 deletions Sources/Container-Compose/ComposeStopOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//===----------------------------------------------------------------------===//
// 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 ContainerResource

/// Converts Compose service shutdown configuration into Apple Container options.
///
/// `signal` remains nil so the container runtime uses the image's configured
/// stop signal, or SIGTERM when the image does not specify one.
enum ComposeStopOptions {
static func resolve(for service: Service) -> ContainerStopOptions {
resolve(timeoutInSeconds: service.stopTimeoutInSeconds)
}

static func resolve(timeoutInSeconds: Int32) -> ContainerStopOptions {
ContainerStopOptions(
timeoutInSeconds: timeoutInSeconds,
signal: nil
)
}
}
Loading