From 3499772a52b9d72013192693deb69a44dc222133 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:13:15 +0200 Subject: [PATCH 1/6] test: inject container executable without global env --- .../ContainerComposeKit/ContainerRunner.swift | 12 ++++--- .../ContainerComposeKitTests.swift | 31 +++++++------------ 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/Sources/ContainerComposeKit/ContainerRunner.swift b/Sources/ContainerComposeKit/ContainerRunner.swift index 3db470e..9217103 100644 --- a/Sources/ContainerComposeKit/ContainerRunner.swift +++ b/Sources/ContainerComposeKit/ContainerRunner.swift @@ -17,12 +17,16 @@ 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) { + /// 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]) { diff --git a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift index db24cb7..687eea7 100644 --- a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift +++ b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift @@ -183,9 +183,7 @@ struct FileObjectTests { } } -// .serialized: these mutate the process-global CONTAINER_CLI env var. - -@Suite("Completed-successfully gating", .serialized) +@Suite("Completed-successfully gating") struct OneShotTests { @Test("dry-run up completes for a stack using service_completed_successfully") func dryRunUp() throws { @@ -204,14 +202,12 @@ struct OneShotTests { // Shim `container` that always exits non-zero; the one-shot `a` then // fails and `up` must throw dependencyFailed before `b` is created. let shim = FileManager.default.temporaryDirectory - .appendingPathComponent("container-fail-shim-\(getpid())") + .appendingPathComponent("container-fail-shim-\(UUID().uuidString)") try "#!/bin/sh\nexit 7\n".write(to: shim, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shim.path) defer { try? FileManager.default.removeItem(at: shim) } - setenv("CONTAINER_CLI", shim.path, 1) - let runner = ContainerRunner() // captures the shim path at init - unsetenv("CONTAINER_CLI") + let runner = ContainerRunner(executable: shim.path) let yaml = """ services: @@ -256,28 +252,23 @@ struct BuildTests { } } -// .serialized: mutates the process-global CONTAINER_CLI env var. - -@Suite("Lifecycle commands", .serialized) +@Suite("Lifecycle commands") struct LifecycleTests { /// Run `body` against an Orchestrator whose `container` is a shim that records /// each invocation, and return the recorded command lines. private func capture(_ tag: String, _ body: (Orchestrator) throws -> Void) throws -> [String] { let dir = FileManager.default.temporaryDirectory - let log = dir.appendingPathComponent("ck-log-\(tag)-\(getpid()).txt") - let shim = dir.appendingPathComponent("ck-shim-\(tag)-\(getpid()).sh") - try? FileManager.default.removeItem(at: log) + .appendingPathComponent("ck-\(tag)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let log = dir.appendingPathComponent("commands.txt") + let shim = dir.appendingPathComponent("container-shim") try "#!/bin/sh\nprintf '%s\\n' \"$*\" >> '\(log.path)'\nexit 0\n" .write(to: shim, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: shim.path) - defer { - try? FileManager.default.removeItem(at: shim) - try? FileManager.default.removeItem(at: log) - } - setenv("CONTAINER_CLI", shim.path, 1) - let runner = ContainerRunner() // captures the shim path at init - unsetenv("CONTAINER_CLI") + let runner = ContainerRunner(executable: shim.path) let yaml = """ services: From a87c0d17b85509dd801c41a2af25ec9d60227a13 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:20:06 +0200 Subject: [PATCH 2/6] refactor: run container commands asynchronously --- Package.resolved | 20 +++- Package.swift | 8 +- README.md | 2 +- .../ContainerComposeKit/ContainerRunner.swift | 98 ++++++++++++------- .../ContainerComposeKit/HealthChecker.swift | 8 +- .../ContainerComposeKit/Orchestrator.swift | 87 ++++++++-------- Sources/container-compose/Commands.swift | 20 ++-- .../ContainerComposeKitTests.swift | 69 +++++++------ .../ContainerRunnerTests.swift | 44 +++++++++ 9 files changed, 232 insertions(+), 124 deletions(-) create mode 100644 Tests/ContainerComposeKitTests/ContainerRunnerTests.swift diff --git a/Package.resolved b/Package.resolved index 2e84fc4..5f524b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b00ccfe3bedf2001e50feb7651d12aeb65675a4a67face1d01831f1a03f79097", + "originHash" : "1f463c5cbf3a597d03cabb3fbf781de6368a1891dc9d5e8b894ab55b3a0f2476", "pins" : [ { "identity" : "composekit", @@ -19,6 +19,24 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess.git", + "state" : { + "revision" : "11633673a41f509f8945f23c96c7acd4adafd679", + "version" : "0.5.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "b5544ba79a70a0cb3563e75bf26dc198d6b40ed3", + "version" : "1.7.4" + } + }, { "identity" : "yams", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1b9a0d3..a2f85ca 100644 --- a/Package.swift +++ b/Package.swift @@ -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 ...`) @@ -17,6 +17,7 @@ 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 @@ -29,7 +30,10 @@ let package = Package( // 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( diff --git a/README.md b/README.md index ee55651..c255286 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/ContainerComposeKit/ContainerRunner.swift b/Sources/ContainerComposeKit/ContainerRunner.swift index 9217103..ace66b8 100644 --- a/Sources/ContainerComposeKit/ContainerRunner.swift +++ b/Sources/ContainerComposeKit/ContainerRunner.swift @@ -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. @@ -17,6 +18,8 @@ public struct ContainerRunner: Sendable { public var verbose: Bool private let executable: String + 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( @@ -35,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(_:)``. diff --git a/Sources/ContainerComposeKit/HealthChecker.swift b/Sources/ContainerComposeKit/HealthChecker.swift index 20106bc..62e380c 100644 --- a/Sources/ContainerComposeKit/HealthChecker.swift +++ b/Sources/ContainerComposeKit/HealthChecker.swift @@ -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 } @@ -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)) } } } diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift index 68a0d14..dde5c0e 100644 --- a/Sources/ContainerComposeKit/Orchestrator.swift +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -52,11 +52,11 @@ public struct Orchestrator: Sendable { /// dependencies); empty means every profile-enabled service. /// - Throws: `ComposeError` for unknown services, dependency cycles, or a /// dependency that fails to become healthy or complete successfully. - public func up(build: Bool, only services: [String], wait: Bool = false) throws { + public func up(build: Bool, only services: [String], wait: Bool = false) async throws { try validate(services) let selected = project.enabledServices(explicit: services) - try ensureNetworks() - try ensureVolumes() + try await ensureNetworks() + try await ensureVolumes() // Services that a selected service depends on with // `condition: service_completed_successfully` — run these to completion. @@ -66,16 +66,16 @@ public struct Orchestrator: Sendable { let order = try Planner.startOrder(project.file.services).filter { selected.contains($0) } for name in order { guard let svc = project.file.services[name] else { continue } - let image = try imageForService( + let image = try await imageForService( name: name, svc: svc, forceBuild: build, buildSecrets: resolved.secrets) warnUnsupported(name: name, svc: svc) // Wait on any `depends_on: condition: service_healthy` dependencies. - try gateDependencies(of: name, svc, selected: selected) + try await gateDependencies(of: name, svc, selected: selected) // Recreate semantics: remove any stale container with the same name. let cname = translator.containerName(service: name, declared: svc.container_name) - runner.runSilently(["delete", "--force", cname]) + try await runner.runSilently(["delete", "--force", cname]) // One-shot dependency: run attached and use the exit code as the gate // (`container` has no `wait`); a non-zero exit aborts `up`. Topological @@ -84,22 +84,22 @@ public struct Orchestrator: Sendable { info("Running \(cname) (one-shot) ...") let args = translator.runArgs( service: name, svc, image: image, detach: false, files: resolved) - let status = try runner.run(args) + let status = try await runner.run(args) if status != 0 { throw ComposeError.dependencyFailed(name, status) } } else { info("Creating \(cname) ...") - try runner.runChecked( + try await runner.runChecked( translator.runArgs(service: name, svc, image: image, files: resolved)) } } - if wait { try waitUntilHealthy(order: order, oneShot: oneShot) } + if wait { try await waitUntilHealthy(order: order, oneShot: oneShot) } } /// Block until every started service that defines a healthcheck reports /// healthy. Services without a healthcheck are considered ready once created; /// one-shot (`service_completed_successfully`) services already ran to exit. - private func waitUntilHealthy(order: [String], oneShot: Set) throws { + private func waitUntilHealthy(order: [String], oneShot: Set) async throws { for name in order where !oneShot.contains(name) { guard let svc = project.file.services[name], let hc = svc.healthcheck, hc.disable != true, let test = hc.test, @@ -107,7 +107,7 @@ public struct Orchestrator: Sendable { else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) info("Waiting for '\(name)' to be healthy ...") - try HealthChecker(runner: runner).waitHealthy(container: cname, health: hc) + try await HealthChecker(runner: runner).waitHealthy(container: cname, health: hc) } } @@ -127,7 +127,9 @@ public struct Orchestrator: Sendable { } /// Block on `service_healthy` dependencies that were also selected for `up`. - private func gateDependencies(of name: String, _ svc: Service, selected: Set) throws { + private func gateDependencies( + of name: String, _ svc: Service, selected: Set + ) async throws { guard let deps = svc.depends_on else { return } for dep in deps.names where selected.contains(dep) { guard deps.condition(for: dep) == "service_healthy" else { continue } @@ -137,7 +139,8 @@ public struct Orchestrator: Sendable { let test = hc.test, HealthChecker.execArguments(for: test) != nil { info("Waiting for '\(dep)' to be healthy ...") - try HealthChecker(runner: runner).waitHealthy(container: depContainer, health: hc) + try await HealthChecker(runner: runner).waitHealthy( + container: depContainer, health: hc) } else { warn( "service '\(name)': depends_on '\(dep)' wants service_healthy but " @@ -251,13 +254,13 @@ public struct Orchestrator: Sendable { private func imageForService( name: String, svc: Service, forceBuild: Bool, buildSecrets: [String: String] - ) throws -> String { + ) async throws -> String { if svc.build != nil, forceBuild || svc.image == nil { if let buildArgs = translator.buildArgs( service: name, svc, resolvedSecrets: buildSecrets) { info("Building \(name) ...") - try runner.runChecked(buildArgs) + try await runner.runChecked(buildArgs) } return svc.image ?? translator.builtImageTag(service: name) } @@ -274,27 +277,27 @@ public struct Orchestrator: Sendable { /// /// - Parameter removeVolumes: also delete the project's declared named /// volumes (data loss); defaults to keeping them. - public func down(removeVolumes: Bool) throws { + public func down(removeVolumes: Bool) async throws { // Stop & remove in reverse dependency order. let order = try Planner.startOrder(project.file.services).reversed() for name in order { guard let svc = project.file.services[name] else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) info("Removing \(cname) ...") - runner.runSilently(["stop", cname]) - runner.runSilently(["delete", "--force", cname]) + try await runner.runSilently(["stop", cname]) + try await runner.runSilently(["delete", "--force", cname]) } // Remove project networks (default + declared). var networks = [translator.defaultNetwork] networks += (project.file.networks ?? [:]).keys.map { translator.networkName($0) } for net in networks { - runner.runSilently(["network", "delete", net]) + try await runner.runSilently(["network", "delete", net]) } if removeVolumes { for volName in (project.file.volumes ?? [:]).keys { - runner.runSilently(["volume", "delete", translator.volumeName(volName)]) + try await runner.runSilently(["volume", "delete", translator.volumeName(volName)]) } } } @@ -305,10 +308,10 @@ public struct Orchestrator: Sendable { /// prefix (the CLI's JSON schema is intentionally not depended upon). /// /// - Parameter all: include stopped containers (`container list --all`). - public func ps(all: Bool) throws { + public func ps(all: Bool) async throws { var args = ["list"] if all { args.append("--all") } - let (status, out) = try runner.capture(args) + let (status, out) = try await runner.capture(args) guard status == 0 else { throw ContainerRunner.RunnerError.nonZeroExit(command: args, status: status) } let prefix = "\(project.name)-" @@ -335,7 +338,9 @@ public struct Orchestrator: Sendable { /// one service this tails them sequentially; pass a single service to /// stream live. /// - services: limit to these services; empty means all. - public func logs(follow: Bool, tail: Int? = nil, only services: [String]) throws { + public func logs( + follow: Bool, tail: Int? = nil, only services: [String] + ) async throws { let selected = try select(services).sorted() if follow && selected.count > 1 { warn("--follow with multiple services tails them sequentially; pass one service to stream live") @@ -347,7 +352,7 @@ public struct Orchestrator: Sendable { if follow { args.append("--follow") } if let tail { args += ["-n", String(tail)] } args.append(cname) - _ = try? runner.run(args) + _ = try? await runner.run(args) } } @@ -370,7 +375,7 @@ public struct Orchestrator: Sendable { public func exec( service: String, command: [String], interactive: Bool = false, tty: Bool = false, workdir: String? = nil, user: String? = nil, env: [String] = [] - ) throws -> Int32 { + ) async throws -> Int32 { try validate([service]) let svc = project.file.services[service] let cname = translator.containerName(service: service, declared: svc?.container_name) @@ -382,7 +387,7 @@ public struct Orchestrator: Sendable { for entry in env { args += ["--env", entry] } args.append(cname) args += command - return try runner.run(args) + return try await runner.run(args) } // MARK: - pull @@ -391,7 +396,7 @@ public struct Orchestrator: Sendable { /// `image:`) and duplicate image references are skipped. /// /// - Parameter services: limit to these services; empty means all. - public func pull(only services: [String]) throws { + public func pull(only services: [String]) async throws { let selected = try select(services) var pulled = Set() for name in selected.sorted() { @@ -399,7 +404,7 @@ public struct Orchestrator: Sendable { else { continue } guard pulled.insert(image).inserted else { continue } info("Pulling \(image) ...") - try runner.runChecked(["image", "pull", image]) + try await runner.runChecked(["image", "pull", image]) } } @@ -407,31 +412,31 @@ public struct Orchestrator: Sendable { /// Stop the selected services' containers (reverse dependency order) without /// removing them. Best-effort: an already-stopped container is ignored. - public func stop(only services: [String]) throws { + public func stop(only services: [String]) async throws { for name in try ordered(services, reversed: true) { guard let svc = project.file.services[name] else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) info("Stopping \(cname) ...") - runner.runSilently(["stop", cname]) + try await runner.runSilently(["stop", cname]) } } /// Start previously-created containers for the selected services (dependency /// order) without recreating them. - public func start(only services: [String]) throws { + public func start(only services: [String]) async throws { for name in try ordered(services, reversed: false) { guard let svc = project.file.services[name] else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) info("Starting \(cname) ...") - try runner.runChecked(["start", cname]) + try await runner.runChecked(["start", cname]) } } /// Restart the selected services (stop then start). `container` has no native /// restart command, so this is implemented as a stop followed by a start. - public func restart(only services: [String]) throws { - try stop(only: services) - try start(only: services) + public func restart(only services: [String]) async throws { + try await stop(only: services) + try await start(only: services) } /// Send a signal (default KILL) to the selected services' containers, in @@ -439,7 +444,7 @@ public struct Orchestrator: Sendable { /// /// - Parameter signal: the signal to send (e.g. `SIGTERM`); `nil` uses the /// `container` default (KILL). - public func kill(only services: [String], signal: String? = nil) throws { + public func kill(only services: [String], signal: String? = nil) async throws { for name in try ordered(services, reversed: true) { guard let svc = project.file.services[name] else { continue } let cname = translator.containerName(service: name, declared: svc.container_name) @@ -447,7 +452,7 @@ public struct Orchestrator: Sendable { var args = ["kill"] if let signal { args += ["--signal", signal] } args.append(cname) - runner.runSilently(args) + try await runner.runSilently(args) } } @@ -473,7 +478,7 @@ public struct Orchestrator: Sendable { return reversed ? order.reversed() : order } - private func ensureNetworks() throws { + private func ensureNetworks() async throws { var toCreate: [(name: String, spec: NetworkSpec?)] = [(translator.defaultNetwork, nil)] for (declared, spec) in project.file.networks ?? [:] { // External networks are assumed to already exist. @@ -487,11 +492,11 @@ public struct Orchestrator: Sendable { args.append(name) // Best-effort and idempotent: an existing network is silently reused // (Docker-compatible). A genuine failure surfaces when `run` uses it. - runner.runSilently(args) + try await runner.runSilently(args) } } - private func ensureVolumes() throws { + private func ensureVolumes() async throws { // Declared top-level named volumes. var names = Set() for (declared, spec) in project.file.volumes ?? [:] { @@ -500,7 +505,7 @@ public struct Orchestrator: Sendable { } for name in names.sorted() { // Idempotent: an existing volume is reused. - runner.runSilently(["volume", "create", translator.volumeName(name)]) + try await runner.runSilently(["volume", "create", translator.volumeName(name)]) } } diff --git a/Sources/container-compose/Commands.swift b/Sources/container-compose/Commands.swift index 0e136ad..b5ce3dd 100644 --- a/Sources/container-compose/Commands.swift +++ b/Sources/container-compose/Commands.swift @@ -24,7 +24,7 @@ struct Up: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.up(build: build, only: services, wait: wait) + try await orchestrator.up(build: build, only: services, wait: wait) } } @@ -39,7 +39,7 @@ struct Down: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.down(removeVolumes: volumes) + try await orchestrator.down(removeVolumes: volumes) } } @@ -54,7 +54,7 @@ struct Ps: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.ps(all: all) + try await orchestrator.ps(all: all) } } @@ -76,7 +76,7 @@ struct Logs: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.logs(follow: follow, tail: tail, only: services) + try await orchestrator.logs(follow: follow, tail: tail, only: services) } } @@ -109,7 +109,7 @@ struct Exec: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - let status = try orchestrator.exec( + let status = try await orchestrator.exec( service: service, command: command, interactive: interactive, tty: tty, workdir: workdir, user: user, env: env) if status != 0 { throw ExitCode(status) } @@ -127,7 +127,7 @@ struct Pull: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.pull(only: services) + try await orchestrator.pull(only: services) } } @@ -142,7 +142,7 @@ struct Stop: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.stop(only: services) + try await orchestrator.stop(only: services) } } @@ -157,7 +157,7 @@ struct Start: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.start(only: services) + try await orchestrator.start(only: services) } } @@ -172,7 +172,7 @@ struct Restart: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.restart(only: services) + try await orchestrator.restart(only: services) } } @@ -190,7 +190,7 @@ struct Kill: AsyncParsableCommand { func run() async throws { let orchestrator = try options.makeOrchestrator() - try orchestrator.kill(only: services, signal: signal) + try await orchestrator.kill(only: services, signal: signal) } } diff --git a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift index 687eea7..015c0cb 100644 --- a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift +++ b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift @@ -186,7 +186,7 @@ struct FileObjectTests { @Suite("Completed-successfully gating") struct OneShotTests { @Test("dry-run up completes for a stack using service_completed_successfully") - func dryRunUp() throws { + func dryRunUp() async throws { // local-dev.yaml gates web on migrate (service_completed_successfully); // a dry run exercises the one-shot detection + attached-run branch. let url = Bundle.module.url( @@ -194,11 +194,11 @@ struct OneShotTests { let project = try Project.load( explicit: url.path, projectName: nil, cwd: url.deletingLastPathComponent()) let orch = Orchestrator(project: project, runner: ContainerRunner(dryRun: true)) - try orch.up(build: false, only: []) + try await orch.up(build: false, only: []) } @Test("a failing one-shot dependency aborts up") - func failingOneShotAborts() throws { + func failingOneShotAborts() async throws { // Shim `container` that always exits non-zero; the one-shot `a` then // fails and `up` must throw dependencyFailed before `b` is created. let shim = FileManager.default.temporaryDirectory @@ -226,7 +226,7 @@ struct OneShotTests { let orch = Orchestrator(project: project, runner: runner) do { - try orch.up(build: false, only: []) + try await orch.up(build: false, only: []) Issue.record("expected up to throw dependencyFailed") } catch let ComposeError.dependencyFailed(name, status) { #expect(name == "a") @@ -256,7 +256,9 @@ struct BuildTests { struct LifecycleTests { /// Run `body` against an Orchestrator whose `container` is a shim that records /// each invocation, and return the recorded command lines. - private func capture(_ tag: String, _ body: (Orchestrator) throws -> Void) throws -> [String] { + private func capture( + _ tag: String, _ body: (Orchestrator) async throws -> Void + ) async throws -> [String] { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("ck-\(tag)-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) @@ -281,37 +283,38 @@ struct LifecycleTests { let project = Project( name: "proj", file: try ComposeFile.parse(yaml: yaml), baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) - try body(Orchestrator(project: project, runner: runner)) + try await body(Orchestrator(project: project, runner: runner)) let text = (try? String(contentsOf: log, encoding: .utf8)) ?? "" return text.split(separator: "\n").map(String.init) } @Test("exec passes -i/-t and the command into the service container") - func exec() throws { - let lines = try capture("exec") { - _ = try $0.exec(service: "db", command: ["psql", "-U", "app"], interactive: true, tty: true) + func exec() async throws { + let lines = try await capture("exec") { + _ = try await $0.exec( + service: "db", command: ["psql", "-U", "app"], interactive: true, tty: true) } #expect(lines.contains("exec --interactive --tty proj-db psql -U app")) } @Test("pull fetches image services and skips build-only ones") - func pull() throws { - let lines = try capture("pull") { try $0.pull(only: []) } + func pull() async throws { + let lines = try await capture("pull") { try await $0.pull(only: []) } #expect(lines.contains("image pull postgres:16")) #expect(!lines.contains { $0.contains("proj-app") }) // app builds locally } @Test("stop runs in reverse dependency order") - func stop() throws { - let lines = try capture("stop") { try $0.stop(only: []) } + func stop() async throws { + let lines = try await capture("stop") { try await $0.stop(only: []) } let app = lines.firstIndex(of: "stop proj-app") let db = lines.firstIndex(of: "stop proj-db") #expect(app != nil && db != nil && app! < db!) // dependent stops first } @Test("restart stops everything before starting anything") - func restart() throws { - let lines = try capture("restart") { try $0.restart(only: []) } + func restart() async throws { + let lines = try await capture("restart") { try await $0.restart(only: []) } #expect(lines.contains("stop proj-db")) #expect(lines.contains("start proj-db")) let lastStop = lines.lastIndex { $0.hasPrefix("stop ") }! @@ -320,8 +323,8 @@ struct LifecycleTests { } @Test("down stops in reverse order, removes the network, keeps volumes") - func down() throws { - let lines = try capture("down") { try $0.down(removeVolumes: false) } + func down() async throws { + let lines = try await capture("down") { try await $0.down(removeVolumes: false) } let app = lines.firstIndex { $0.hasPrefix("stop proj-app") } let db = lines.firstIndex { $0.hasPrefix("stop proj-db") } #expect(app != nil && db != nil && app! < db!) // dependent stops first @@ -330,29 +333,31 @@ struct LifecycleTests { } @Test("kill sends the signal in reverse order") - func kill() throws { - let lines = try capture("kill") { try $0.kill(only: [], signal: "SIGTERM") } + func kill() async throws { + let lines = try await capture("kill") { try await $0.kill(only: [], signal: "SIGTERM") } let app = lines.firstIndex(of: "kill --signal SIGTERM proj-app") let db = lines.firstIndex(of: "kill --signal SIGTERM proj-db") #expect(app != nil && db != nil && app! < db!) } @Test("kill with no signal omits --signal (container default KILL)") - func killDefault() throws { - let lines = try capture("killd") { try $0.kill(only: ["db"]) } + func killDefault() async throws { + let lines = try await capture("killd") { try await $0.kill(only: ["db"]) } #expect(lines.contains("kill proj-db")) } @Test("logs --tail emits -n") - func logsTail() throws { - let lines = try capture("logs") { try $0.logs(follow: false, tail: 25, only: ["db"]) } + func logsTail() async throws { + let lines = try await capture("logs") { + try await $0.logs(follow: false, tail: 25, only: ["db"]) + } #expect(lines.contains("logs -n 25 proj-db")) } @Test("exec forwards --workdir/--user/--env") - func execFlags() throws { - let lines = try capture("exec") { - _ = try $0.exec( + func execFlags() async throws { + let lines = try await capture("exec") { + _ = try await $0.exec( service: "db", command: ["sh"], interactive: true, tty: true, workdir: "/app", user: "1000", env: ["A=b"]) } @@ -399,18 +404,22 @@ struct OrchestratorErrorTests { } @Test("up on a service with neither image nor build throws serviceMissingImage") - func missingImage() throws { + func missingImage() async throws { let orch = Orchestrator( project: try project("services:\n x: {}\n"), runner: ContainerRunner(dryRun: true)) - #expect(throws: ComposeError.self) { try orch.up(build: false, only: []) } + await #expect(throws: ComposeError.self) { + try await orch.up(build: false, only: []) + } } @Test("exec on an unknown service throws unknownService") - func unknownExec() throws { + func unknownExec() async throws { let orch = Orchestrator( project: try project("services:\n a:\n image: x\n"), runner: ContainerRunner(dryRun: true)) - #expect(throws: ComposeError.self) { _ = try orch.exec(service: "nope", command: ["sh"]) } + await #expect(throws: ComposeError.self) { + _ = try await orch.exec(service: "nope", command: ["sh"]) + } } } diff --git a/Tests/ContainerComposeKitTests/ContainerRunnerTests.swift b/Tests/ContainerComposeKitTests/ContainerRunnerTests.swift new file mode 100644 index 0000000..48e6056 --- /dev/null +++ b/Tests/ContainerComposeKitTests/ContainerRunnerTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing + +@testable import ContainerComposeKit + +@Suite("Container runner") +struct ContainerRunnerTests { + @Test("capture returns status and stdout") + func capture() async throws { + let runner = ContainerRunner(executable: "/bin/sh") + let result = try await runner.capture(["-c", "printf hello"]) + + #expect(result.status == 0) + #expect(result.stdout == "hello") + } + + @Test("silent spawn failures return minus one") + func spawnFailure() async throws { + let runner = ContainerRunner(executable: "container-compose-missing-\(UUID().uuidString)") + #expect(try await runner.runSilently([]) == -1) + } + + @Test("cancelling a run terminates the child and throws cancellation") + func cancellation() async throws { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("container-runner-started-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: marker) } + + let runner = ContainerRunner(executable: "/bin/sh") + let task = Task { + try await runner.runSilently(["-c", "echo started > '\(marker.path)'; exec sleep 30"]) + } + + for _ in 0..<100 where !FileManager.default.fileExists(atPath: marker.path) { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(FileManager.default.fileExists(atPath: marker.path)) + + task.cancel() + await #expect(throws: CancellationError.self) { + try await task.value + } + } +} From 6d0bc0a7a0192076f509ec46dd16abb559d3a970 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:22:35 +0200 Subject: [PATCH 3/6] perf: wait for health checks concurrently --- .../ContainerComposeKit/Orchestrator.swift | 75 ++++++-- .../OrchestratorConcurrencyTests.swift | 165 ++++++++++++++++++ 2 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift index dde5c0e..1b99a9e 100644 --- a/Sources/ContainerComposeKit/Orchestrator.swift +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -64,6 +64,7 @@ public struct Orchestrator: Sendable { let resolved = resolveFileObjects(selected: selected) let order = try Planner.startOrder(project.file.services).filter { selected.contains($0) } + var healthyDependencies = Set() for name in order { guard let svc = project.file.services[name] else { continue } let image = try await imageForService( @@ -71,7 +72,9 @@ public struct Orchestrator: Sendable { warnUnsupported(name: name, svc: svc) // Wait on any `depends_on: condition: service_healthy` dependencies. - try await gateDependencies(of: name, svc, selected: selected) + let newlyHealthy = try await gateDependencies( + of: name, svc, selected: selected, alreadyHealthy: healthyDependencies) + healthyDependencies.formUnion(newlyHealthy) // Recreate semantics: remove any stale container with the same name. let cname = translator.containerName(service: name, declared: svc.container_name) @@ -100,14 +103,30 @@ public struct Orchestrator: Sendable { /// healthy. Services without a healthcheck are considered ready once created; /// one-shot (`service_completed_successfully`) services already ran to exit. private func waitUntilHealthy(order: [String], oneShot: Set) async throws { + var checks: [(name: String, container: String, health: Healthcheck)] = [] for name in order where !oneShot.contains(name) { - guard let svc = project.file.services[name], let hc = svc.healthcheck, - hc.disable != true, let test = hc.test, + guard let svc = project.file.services[name], let health = svc.healthcheck, + health.disable != true, let test = health.test, HealthChecker.execArguments(for: test) != nil else { continue } - let cname = translator.containerName(service: name, declared: svc.container_name) - info("Waiting for '\(name)' to be healthy ...") - try await HealthChecker(runner: runner).waitHealthy(container: cname, health: hc) + checks.append( + ( + name, + translator.containerName(service: name, declared: svc.container_name), + health + )) + } + + let checker = HealthChecker(runner: runner) + try await withThrowingTaskGroup(of: Void.self) { group in + for check in checks { + info("Waiting for '\(check.name)' to be healthy ...") + group.addTask { + try await checker.waitHealthy( + container: check.container, health: check.health) + } + } + while let _ = try await group.next() {} } } @@ -127,26 +146,50 @@ public struct Orchestrator: Sendable { } /// Block on `service_healthy` dependencies that were also selected for `up`. + /// Dependencies that passed an earlier gate are not polled again. private func gateDependencies( - of name: String, _ svc: Service, selected: Set - ) async throws { - guard let deps = svc.depends_on else { return } - for dep in deps.names where selected.contains(dep) { + of name: String, _ svc: Service, selected: Set, + alreadyHealthy: Set + ) async throws -> Set { + guard let deps = svc.depends_on else { return [] } + var checks: [(name: String, container: String, health: Healthcheck)] = [] + for dep in deps.names where selected.contains(dep) && !alreadyHealthy.contains(dep) { guard deps.condition(for: dep) == "service_healthy" else { continue } guard let depSvc = project.file.services[dep] else { continue } - let depContainer = translator.containerName(service: dep, declared: depSvc.container_name) - if let hc = depSvc.healthcheck, hc.disable != true, - let test = hc.test, HealthChecker.execArguments(for: test) != nil + if let health = depSvc.healthcheck, health.disable != true, + let test = health.test, HealthChecker.execArguments(for: test) != nil { - info("Waiting for '\(dep)' to be healthy ...") - try await HealthChecker(runner: runner).waitHealthy( - container: depContainer, health: hc) + checks.append( + ( + dep, + translator.containerName( + service: dep, declared: depSvc.container_name), + health + )) } else { warn( "service '\(name)': depends_on '\(dep)' wants service_healthy but " + "'\(dep)' defines no healthcheck; starting without gating") } } + + let checker = HealthChecker(runner: runner) + return try await withThrowingTaskGroup(of: String.self) { group in + for check in checks { + info("Waiting for '\(check.name)' to be healthy ...") + group.addTask { + try await checker.waitHealthy( + container: check.container, health: check.health) + return check.name + } + } + + var healthy = Set() + while let name = try await group.next() { + healthy.insert(name) + } + return healthy + } } /// Resolve every config/secret source referenced by a selected service to a diff --git a/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift b/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift new file mode 100644 index 0000000..45989af --- /dev/null +++ b/Tests/ContainerComposeKitTests/OrchestratorConcurrencyTests.swift @@ -0,0 +1,165 @@ +import Foundation +import Testing + +import ComposeKit +@testable import ContainerComposeKit + +@Suite("Concurrent health gating") +struct OrchestratorConcurrencyTests { + @Test("up --wait checks independent services concurrently") + func waitChecksConcurrently() async throws { + try await withShim { runner, directory in + let barrier = barrierScript(directory: directory, containers: ["p-a", "p-b"]) + try writeShim(barrier, to: runner.shim) + + let project = try makeProject( + """ + services: + a: + image: x + healthcheck: + test: ["CMD", "true"] + retries: 1 + b: + image: x + healthcheck: + test: ["CMD", "true"] + retries: 1 + """) + let orchestrator = Orchestrator( + project: project, runner: ContainerRunner(executable: runner.shim.path)) + + try await orchestrator.up(build: false, only: [], wait: true) + } + } + + @Test("multiple healthy dependencies are gated concurrently") + func dependencyChecksConcurrently() async throws { + try await withShim { runner, directory in + let barrier = barrierScript(directory: directory, containers: ["p-a", "p-b"]) + try writeShim(barrier, to: runner.shim) + + let project = try makeProject( + """ + services: + a: + image: x + healthcheck: + test: ["CMD", "true"] + retries: 1 + b: + image: x + healthcheck: + test: ["CMD", "true"] + retries: 1 + app: + image: x + depends_on: + a: + condition: service_healthy + b: + condition: service_healthy + """) + let orchestrator = Orchestrator( + project: project, runner: ContainerRunner(executable: runner.shim.path)) + + try await orchestrator.up(build: false, only: [], wait: false) + } + } + + @Test("a dependency that passed a health gate is not polled again") + func successfulGateIsCached() async throws { + try await withShim { runner, _ in + let script = """ + #!/bin/sh + if [ "$1" = "exec" ]; then + printf '%s\\n' "$*" >> '\(runner.log.path)' + fi + exit 0 + """ + try writeShim(script, to: runner.shim) + + let project = try makeProject( + """ + services: + db: + image: x + healthcheck: + test: ["CMD", "true"] + retries: 1 + app1: + image: x + depends_on: + db: + condition: service_healthy + app2: + image: x + depends_on: + db: + condition: service_healthy + """) + let orchestrator = Orchestrator( + project: project, runner: ContainerRunner(executable: runner.shim.path)) + + try await orchestrator.up(build: false, only: [], wait: false) + + let log = (try? String(contentsOf: runner.log, encoding: .utf8)) ?? "" + let checks = log.split(separator: "\n").filter { $0.hasPrefix("exec p-db ") } + #expect(checks.count == 1) + } + } + + private struct ShimFiles { + let shim: URL + let log: URL + } + + private func withShim( + _ operation: (ShimFiles, URL) async throws -> Void + ) async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("orchestrator-health-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + + try await operation( + ShimFiles( + shim: directory.appendingPathComponent("container-shim"), + log: directory.appendingPathComponent("commands.txt")), + directory) + } + + private func writeShim(_ script: String, to url: URL) throws { + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private func barrierScript(directory: URL, containers: [String]) -> String { + let conditions = containers + .map { "[ -f '\(directory.path)/\($0)' ]" } + .joined(separator: " && ") + return """ + #!/bin/sh + if [ "$1" = "exec" ]; then + touch '\(directory.path)/'"$2" + attempts=0 + while [ "$attempts" -lt 100 ]; do + if \(conditions); then + exit 0 + fi + sleep 0.01 + attempts=$((attempts + 1)) + done + exit 1 + fi + exit 0 + """ + } + + private func makeProject(_ yaml: String) throws -> Project { + Project( + name: "p", file: try ComposeFile.parse(yaml: yaml), + baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) + } +} From 4498da0c08c091adbc69578de1669bd34cf898c3 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:23:10 +0200 Subject: [PATCH 4/6] perf: stream installer downloads to disk --- Sources/container-compose/Update.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/Sources/container-compose/Update.swift b/Sources/container-compose/Update.swift index 85b199d..49fd3ea 100644 --- a/Sources/container-compose/Update.swift +++ b/Sources/container-compose/Update.swift @@ -72,14 +72,22 @@ struct Update: AsyncParsableCommand { private func download(_ name: String, from url: URL) async throws -> URL { print("Downloading \(name) ...") - let (data, response) = try await URLSession.shared.data(from: url) + let (temporary, response) = try await URLSession.shared.download(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + try? FileManager.default.removeItem(at: temporary) throw Fail("download failed (HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1))") } - let dest = FileManager.default.temporaryDirectory.appendingPathComponent(name) - try? FileManager.default.removeItem(at: dest) - try data.write(to: dest) - return dest + + let filename = URL(fileURLWithPath: name).lastPathComponent + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("\(UUID().uuidString)-\(filename)") + do { + try FileManager.default.moveItem(at: temporary, to: destination) + return destination + } catch { + try? FileManager.default.removeItem(at: temporary) + throw error + } } private func verifySignature(of pkg: URL) throws { From 5f7803e8508ad6ccaf6921e41bb8eb697ed79953 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:24:04 +0200 Subject: [PATCH 5/6] refactor: run updater tools asynchronously --- Sources/container-compose/Update.swift | 41 +++++++------------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/Sources/container-compose/Update.swift b/Sources/container-compose/Update.swift index 49fd3ea..f7fc8fe 100644 --- a/Sources/container-compose/Update.swift +++ b/Sources/container-compose/Update.swift @@ -5,6 +5,7 @@ // location and prompts for admin). Works regardless of how it was installed. import ArgumentParser +import ContainerComposeKit import Foundation private let repoSlug = "flaticols/container-compose" @@ -50,8 +51,8 @@ struct Update: AsyncParsableCommand { let pkg = try await download(asset.name, from: assetURL) defer { try? FileManager.default.removeItem(at: pkg) } - try verifySignature(of: pkg) - try install(pkg) + try await verifySignature(of: pkg) + try await install(pkg) print("Updated to \(latestTag). Run `container-compose --version` to confirm.") } @@ -90,21 +91,23 @@ struct Update: AsyncParsableCommand { } } - private func verifySignature(of pkg: URL) throws { + private func verifySignature(of pkg: URL) async throws { // A signed, notarized .pkg makes `pkgutil --check-signature` exit 0. - let (status, _) = capture("/usr/sbin/pkgutil", ["--check-signature", pkg.path]) + let runner = ContainerRunner(executable: "/usr/sbin/pkgutil") + let status = try await runner.runSilently(["--check-signature", pkg.path]) guard status == 0 else { throw Fail("the downloaded .pkg is not signed/notarized; refusing to install") } print("Signature verified.") } - private func install(_ pkg: URL) throws { + private func install(_ pkg: URL) async throws { // Installing into /usr/local needs admin; `sudo installer` prompts in the // terminal and places the plugin where `container` looks for it. print("Installing (you may be prompted for your password) ...") - let status = runInheriting( - "/usr/bin/sudo", ["/usr/sbin/installer", "-pkg", pkg.path, "-target", "/"]) + let runner = ContainerRunner(executable: "/usr/bin/sudo") + let status = try await runner.run( + ["/usr/sbin/installer", "-pkg", pkg.path, "-target", "/"]) guard status == 0 else { throw Fail("installer exited with status \(status)") } } } @@ -149,31 +152,7 @@ struct SemVer: Comparable { } } -// MARK: - Process helpers - private struct Fail: Error, CustomStringConvertible { let description: String init(_ message: String) { self.description = message } } - -private func runInheriting(_ launchPath: String, _ args: [String]) -> Int32 { - let p = Process() - p.executableURL = URL(fileURLWithPath: launchPath) - p.arguments = args - do { try p.run() } catch { return -1 } - p.waitUntilExit() - return p.terminationStatus -} - -private func capture(_ launchPath: String, _ args: [String]) -> (status: Int32, output: String) { - let p = Process() - p.executableURL = URL(fileURLWithPath: launchPath) - p.arguments = args - let pipe = Pipe() - p.standardOutput = pipe - p.standardError = pipe - do { try p.run() } catch { return (-1, "") } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - p.waitUntilExit() - return (p.terminationStatus, String(data: data, encoding: .utf8) ?? "") -} From 73c20ec9bdd30dffc3fddd27f98505f4c7fb7cb2 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 12 Jul 2026 12:25:21 +0200 Subject: [PATCH 6/6] deps: bump ComposeKit to 0.0.4 --- Package.resolved | 6 +++--- Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index 5f524b3..c83dc75 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "1f463c5cbf3a597d03cabb3fbf781de6368a1891dc9d5e8b894ab55b3a0f2476", + "originHash" : "0839ee7480a769f38d32417692afe859f4283e7b573e253fb041109acbd9a861", "pins" : [ { "identity" : "composekit", "kind" : "remoteSourceControl", "location" : "https://github.com/flaticols/ComposeKit.git", "state" : { - "revision" : "bebaef274da3123db3c2f405dbbaf2e04d9486ad", - "version" : "0.0.3" + "revision" : "ded92ba664f622b714de14e86d283cebdf58ef8d", + "version" : "0.0.4" } }, { diff --git a/Package.swift b/Package.swift index a2f85ca..299b737 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ let package = Package( // `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`