Skip to content
Merged
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
24 changes: 21 additions & 3 deletions Package.resolved

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

10 changes: 7 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.0
// swift-tools-version: 6.2
// container-compose — Docker Compose compatibility layer for Apple's `container`.
//
// Ships as a CLI plugin for `container` (invoked as `container compose ...`)
Expand All @@ -17,19 +17,23 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", exact: "0.5.0"),
// ComposeKit is the runtime-agnostic Compose parser, in its own repo.
// Pinned to an exact tag — for a 0.0.x line every patch may break, and
// `from:` would float up to <1.0.0. Bump this string when adopting a
// newer ComposeKit. For local changes, use
// `swift package edit ComposeKit --path ../ComposeKit`. See PACKAGING.md.
.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"),
.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.4"),
],
targets: [
// The `container` runtime layer: maps the parsed model onto `container`
// run/build args and orchestrates up/down/ps/logs/exec/pull/stop/start.
.target(
name: "ContainerComposeKit",
dependencies: [.product(name: "ComposeKit", package: "ComposeKit")],
dependencies: [
.product(name: "ComposeKit", package: "ComposeKit"),
.product(name: "Subprocess", package: "swift-subprocess"),
],
path: "Sources/ContainerComposeKit"
),
.executableTarget(
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ sudo installer -pkg container-compose-*.pkg -target /
container system stop && container system start # reload plugins
```

**From source** (needs the Swift toolchain):
**From source** (needs Swift 6.2 or newer):

```sh
make # swift build -c release
Expand Down
110 changes: 71 additions & 39 deletions Sources/ContainerComposeKit/ContainerRunner.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import ComposeKit
import Foundation
import Subprocess

/// Runs the `container` CLI as a subprocess.
/// Runs the `container` CLI as an asynchronous subprocess.
///
/// ComposeKit drives the stable public `container` command line rather than the
/// internal XPC API. The executable is resolved from the `CONTAINER_CLI`
/// environment variable, falling back to `container` on `PATH` — set
/// `CONTAINER_CLI` to point at a different binary (or a test shim).
/// internal XPC API. The executable is resolved from an explicit initializer
/// override, the `CONTAINER_CLI` environment variable, or `container` on
/// `PATH` — set an override to point at a different binary (or a test shim).
///
/// Set ``dryRun`` to print commands without executing them, and ``verbose`` to
/// trace each invocation to standard error.
Expand All @@ -17,12 +18,18 @@ public struct ContainerRunner: Sendable {
public var verbose: Bool
private let executable: String

/// Create a runner, resolving the executable from `CONTAINER_CLI` (or
/// `container` on `PATH`).
public init(dryRun: Bool = false, verbose: Bool = false) {
private static let captureLimit = 16 * 1024 * 1024

/// Create a runner, resolving the executable from an explicit override,
/// `CONTAINER_CLI`, or `container` on `PATH` (in that order).
public init(
dryRun: Bool = false, verbose: Bool = false, executable: String? = nil
) {
self.dryRun = dryRun
self.verbose = verbose
self.executable = ProcessInfo.processInfo.environment["CONTAINER_CLI"] ?? "container"
self.executable = executable
?? ProcessInfo.processInfo.environment["CONTAINER_CLI"]
?? "container"
}

private func trace(_ args: [String]) {
Expand All @@ -31,63 +38,88 @@ public struct ContainerRunner: Sendable {
}
}

private func makeProcess(_ args: [String]) -> Process {
let p = Process()
// Resolve via env so PATH is honored without hardcoding /usr/local/bin.
p.executableURL = URL(fileURLWithPath: "/usr/bin/env")
p.arguments = [executable] + args
return p
private var processOptions: PlatformOptions {
var options = PlatformOptions()
options.teardownSequence = [
.gracefulShutDown(allowedDurationToNextStep: .seconds(1))
]
return options
}

private func status(_ termination: TerminationStatus) -> Int32 {
switch termination {
case .exited(let code), .signaled(let code): return code
}
}

/// Run inheriting stdio. Returns the exit status.
/// Run asynchronously while inheriting stdio. Returns the exit status.
@discardableResult
public func run(_ args: [String]) throws -> Int32 {
public func run(_ args: [String]) async throws -> Int32 {
trace(args)
try Task.checkCancellation()
if dryRun { return 0 }
let p = makeProcess(args)
try p.run()
p.waitUntilExit()
return p.terminationStatus
let result = try await Subprocess.run(
.name(executable),
arguments: Arguments(args),
platformOptions: processOptions,
input: .standardInput,
output: .currentStandardOutput,
error: .currentStandardError
)
try Task.checkCancellation()
return status(result.terminationStatus)
}

/// Run a best-effort command, discarding stdout/stderr and never throwing.
/// Run a best-effort command asynchronously, discarding stdout/stderr.
/// Used for idempotent cleanup (e.g. removing a possibly-absent container).
/// Spawn failures are reported as `-1`; task cancellation is propagated.
@discardableResult
public func runSilently(_ args: [String]) -> Int32 {
public func runSilently(_ args: [String]) async throws -> Int32 {
trace(args)
try Task.checkCancellation()
if dryRun { return 0 }
let p = makeProcess(args)
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
do {
try p.run()
let result = try await Subprocess.run(
.name(executable),
arguments: Arguments(args),
platformOptions: processOptions,
input: .standardInput,
output: .discarded,
error: .discarded
)
try Task.checkCancellation()
return status(result.terminationStatus)
} catch is CancellationError {
throw CancellationError()
} catch {
return -1
}
p.waitUntilExit()
return p.terminationStatus
}

/// Run inheriting stdio, throwing ``RunnerError/nonZeroExit(command:status:)``
/// if the command exits non-zero.
public func runChecked(_ args: [String]) throws {
let status = try run(args)
public func runChecked(_ args: [String]) async throws {
let status = try await run(args)
if status != 0 {
throw RunnerError.nonZeroExit(command: args, status: status)
}
}

/// Run and capture stdout. stderr is inherited.
public func capture(_ args: [String]) throws -> (status: Int32, stdout: String) {
/// Run asynchronously and capture up to 16 MiB of stdout. stderr is inherited.
public func capture(_ args: [String]) async throws -> (status: Int32, stdout: String) {
trace(args)
try Task.checkCancellation()
if dryRun { return (0, "") }
let p = makeProcess(args)
let pipe = Pipe()
p.standardOutput = pipe
try p.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return (p.terminationStatus, String(data: data, encoding: .utf8) ?? "")
let result = try await Subprocess.run(
.name(executable),
arguments: Arguments(args),
platformOptions: processOptions,
input: .standardInput,
output: .string(limit: Self.captureLimit),
error: .currentStandardError
)
try Task.checkCancellation()
return (status(result.terminationStatus), result.standardOutput ?? "")
}

/// Errors thrown by ``runChecked(_:)``.
Expand Down
8 changes: 4 additions & 4 deletions Sources/ContainerComposeKit/HealthChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct HealthChecker: Sendable {

/// Block until the container's healthcheck passes, or throw after the
/// configured number of retries is exhausted.
public func waitHealthy(container: String, health: Healthcheck) throws {
public func waitHealthy(container: String, health: Healthcheck) async throws {
guard let test = health.test, let execArgs = Self.execArguments(for: test) else {
return // no test / NONE => nothing to gate on
}
Expand All @@ -28,16 +28,16 @@ public struct HealthChecker: Sendable {
let retries = max(health.retries ?? 3, 1)

if startPeriod > 0, !runner.dryRun {
Thread.sleep(forTimeInterval: startPeriod)
try await Task.sleep(for: .seconds(startPeriod))
}

var attempt = 0
while true {
attempt += 1
let (status, _) = try runner.capture(["exec", container] + execArgs)
let (status, _) = try await runner.capture(["exec", container] + execArgs)
if status == 0 { return }
if attempt >= retries { throw ComposeError.dependencyUnhealthy(container) }
if !runner.dryRun { Thread.sleep(forTimeInterval: interval) }
if !runner.dryRun { try await Task.sleep(for: .seconds(interval)) }
}
}

Expand Down
Loading