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
8 changes: 5 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Builds container-compose. The library/unit tests now live in the ComposeKit
# repo (github.com/flaticols/ComposeKit) and run in its own CI. ComposeKit is
# resolved over its Git URL (see Package.swift), so no sibling checkout needed.
# Builds and tests container-compose. The runtime layer (ContainerComposeKit)
# and its tests live in this repo; the Compose spec parser and its tests live in
# the ComposeKit repo. ComposeKit is resolved over its Git URL (see Package.swift).

name: CI

Expand All @@ -18,5 +18,7 @@ jobs:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Build
run: swift build -v
- name: Test
run: swift test
- name: Smoke test (CLI responds)
run: swift run container-compose --help
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# Package.resolved IS committed: this is an executable that ships release
# artifacts, and it tracks ComposeKit's `main` branch — committing the resolved
# file pins the exact ComposeKit revision so CI/release builds are reproducible.
Packages/
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ release:
debug:
swift build -c debug

# Unit tests live in the ComposeKit package (this repo is a thin CLI frontend).
# Runtime-layer tests (ContainerComposeKit) live here; spec-parser tests live in
# the ComposeKit package.
test:
swift test --package-path ../ComposeKit
swift test

clean:
swift package clean
Expand Down
16 changes: 11 additions & 5 deletions PACKAGING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ ArgumentParser frontend that depends on it.

## 1. The ComposeKit dependency

`Package.swift` depends on ComposeKit over its Git URL, tracking `main` until the
first tagged release:
ComposeKit is the runtime-agnostic Compose **parser** (parsing, interpolation,
profiles, planning, include/extends). The `container` **runtime layer**
(translation + orchestration) lives in this repo as the `ContainerComposeKit`
target — it is no longer a `ComposeKitContainer` product of ComposeKit.

`Package.swift` pins ComposeKit to an exact tag:

```swift
.package(url: "https://github.com/flaticols/ComposeKit.git", branch: "main"),
.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"),
```

`Package.resolved` is **committed** (see `.gitignore`) so CI and release builds
pin the exact ComposeKit revision instead of floating to `main`'s HEAD.
`0.0.3` is the first ComposeKit release after the runtime layer was extracted
into this repo (it removed the `ComposeKitContainer` product). `Package.resolved`
is **committed** (see `.gitignore`) so CI and release builds pin the exact
ComposeKit revision.

**Local development against a ComposeKit working copy** — don't edit the line
above; override it with an editable checkout:
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

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

33 changes: 24 additions & 9 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// swift-tools-version: 6.0
//===----------------------------------------------------------------------===//
// container-compose — Docker Compose compatibility layer for Apple's `container`.
//
// Ships as a CLI plugin for `container` (invoked as `container compose ...`)
// and as a standalone `container-compose` binary. It parses a Compose file and
// orchestrates the stable public `container` CLI (Option A — no internal APIs).
//===----------------------------------------------------------------------===//
// and as a standalone `container-compose` binary. It parses a Compose file with
// ComposeKit and orchestrates the stable public `container` CLI (Option A — no
// internal APIs). The runtime layer (ContainerComposeKit) lives here; ComposeKit
// is the runtime-agnostic spec parser.

import PackageDescription

Expand All @@ -17,23 +17,38 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
// ComposeKit lives in its own repo, split into a runtime-agnostic core
// (ComposeKit) and the `container` runtime layer (ComposeKitContainer).
// 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.2"),
.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"),
],
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")],
path: "Sources/ContainerComposeKit"
),
.executableTarget(
name: "container-compose",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "ComposeKit", package: "ComposeKit"),
.product(name: "ComposeKitContainer", package: "ComposeKit"),
"ContainerComposeKit",
],
path: "Sources/container-compose"
)
),
.testTarget(
name: "ContainerComposeKitTests",
dependencies: [
"ContainerComposeKit",
.product(name: "ComposeKit", package: "ComposeKit"),
],
path: "Tests/ContainerComposeKitTests",
resources: [.copy("Fixtures")]
),
]
)
16 changes: 11 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,20 @@ public surface and sidesteps the in-tree plugin property-passthrough blockers

## Architecture

The Compose engine lives in a separate package,
[`ComposeKit`](https://github.com/flaticols/ComposeKit) (parsing, planning,
translation, orchestration — no CLI deps). This repo is the ArgumentParser
frontend that depends on it and ships as the `container` plugin.
The Compose **parser** lives in a separate package,
[`ComposeKit`](https://github.com/flaticols/ComposeKit) (parsing, interpolation,
profiles, planning, include/extends — no runtime or CLI deps). The `container`
**runtime layer** (translation + orchestration) lives in this repo as the
`ContainerComposeKit` target, and the executable is the ArgumentParser frontend.

```
Sources/
container-compose/ # executable (ArgumentParser) — depends on ComposeKit
ContainerComposeKit/ # runtime layer — maps the model onto `container`
ContainerTranslator.swift # Service -> `container run/build` args
ContainerRunner.swift # subprocess wrapper around `container`
Orchestrator.swift # up/down/ps/logs/exec/pull/stop/start/restart
HealthChecker.swift # healthcheck polling + service_healthy gating
container-compose/ # executable (ArgumentParser)
ContainerCompose.swift # root command + global options
Commands.swift # up, down, ps, logs, config
config.toml # plugin manifest (installed alongside the binary)
Expand Down
105 changes: 105 additions & 0 deletions Sources/ContainerComposeKit/ContainerRunner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import ComposeKit
import Foundation

/// Runs the `container` CLI as a 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).
///
/// Set ``dryRun`` to print commands without executing them, and ``verbose`` to
/// trace each invocation to standard error.
public struct ContainerRunner: Sendable {
/// When `true`, commands are traced but never executed; runs report success.
public var dryRun: Bool
/// When `true`, every command is echoed to standard error before running.
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) {
self.dryRun = dryRun
self.verbose = verbose
self.executable = ProcessInfo.processInfo.environment["CONTAINER_CLI"] ?? "container"
}

private func trace(_ args: [String]) {
if verbose || dryRun {
FileHandle.standardError.write(Data("+ \(executable) \(args.joined(separator: " "))\n".utf8))
}
}

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
}

/// Run inheriting stdio. Returns the exit status.
@discardableResult
public func run(_ args: [String]) throws -> Int32 {
trace(args)
if dryRun { return 0 }
let p = makeProcess(args)
try p.run()
p.waitUntilExit()
return p.terminationStatus
}

/// Run a best-effort command, discarding stdout/stderr and never throwing.
/// Used for idempotent cleanup (e.g. removing a possibly-absent container).
@discardableResult
public func runSilently(_ args: [String]) -> Int32 {
trace(args)
if dryRun { return 0 }
let p = makeProcess(args)
p.standardOutput = FileHandle.nullDevice
p.standardError = FileHandle.nullDevice
do {
try p.run()
} 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)
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) {
trace(args)
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) ?? "")
}

/// Errors thrown by ``runChecked(_:)``.
public enum RunnerError: Error, CustomStringConvertible {
/// A command exited with a non-zero status.
case nonZeroExit(command: [String], status: Int32)

public var description: String {
switch self {
case .nonZeroExit(let command, let status):
return "`container \(command.joined(separator: " "))` exited with status \(status)"
}
}
}
}
Loading