From 3515f2de5a49630e8adae08d22d731e94ecd9f3f Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 21 Jun 2026 13:33:21 +0200 Subject: [PATCH 1/2] Absorb the container runtime layer from ComposeKit ComposeKit became a pure spec parser; its container translation/orchestration layer now lives here as the ContainerComposeKit target (ContainerTranslator, ContainerRunner, Orchestrator, HealthChecker), with the container test suites. - Add ContainerComposeKit library target + ContainerComposeKitTests (25 tests). - Executable drops the ComposeKitContainer product dep, gains ContainerComposeKit; imports updated. - Makefile/CI now run local tests; README/PACKAGING reflect the new layout. - gitignore the swift-package-edit Packages/ symlink. Requires the next ComposeKit tag (post-0.0.2, which removed the ComposeKitContainer product); until then build with: swift package edit ComposeKit --path ../ComposeKit --- .github/workflows/ci.yml | 8 +- .gitignore | 1 + Makefile | 5 +- PACKAGING.md | 19 +- Package.swift | 31 +- README.md | 16 +- .../ContainerComposeKit/ContainerRunner.swift | 105 ++++ .../ContainerTranslator.swift | 347 +++++++++++++ .../ContainerComposeKit/HealthChecker.swift | 106 ++++ .../ContainerComposeKit/Orchestrator.swift | 482 ++++++++++++++++++ Sources/container-compose/Commands.swift | 2 +- .../container-compose/ContainerCompose.swift | 2 +- .../ContainerComposeKitTests.swift | 400 +++++++++++++++ .../Fixtures/compose.yaml | 55 ++ .../Fixtures/corpus/build-advanced.yaml | 23 + .../Fixtures/corpus/configs-secrets.yaml | 30 ++ .../Fixtures/corpus/local-dev.yaml | 64 +++ .../Fixtures/corpus/resources.yaml | 32 ++ 18 files changed, 1704 insertions(+), 24 deletions(-) create mode 100644 Sources/ContainerComposeKit/ContainerRunner.swift create mode 100644 Sources/ContainerComposeKit/ContainerTranslator.swift create mode 100644 Sources/ContainerComposeKit/HealthChecker.swift create mode 100644 Sources/ContainerComposeKit/Orchestrator.swift create mode 100644 Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift create mode 100644 Tests/ContainerComposeKitTests/Fixtures/compose.yaml create mode 100644 Tests/ContainerComposeKitTests/Fixtures/corpus/build-advanced.yaml create mode 100644 Tests/ContainerComposeKitTests/Fixtures/corpus/configs-secrets.yaml create mode 100644 Tests/ContainerComposeKitTests/Fixtures/corpus/local-dev.yaml create mode 100644 Tests/ContainerComposeKitTests/Fixtures/corpus/resources.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d4be52..861b247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 0e60a35..13d2925 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 5f60635..dead3c4 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/PACKAGING.md b/PACKAGING.md index 02ff465..6c3b8fd 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -11,15 +11,26 @@ 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.2"), ``` +> **Pending bump:** the runtime layer was extracted from ComposeKit, so this repo +> now requires the ComposeKit release that removed the `ComposeKitContainer` +> product (the next tag after `0.0.2`, e.g. `0.0.3`). Until that tag exists, +> build against a local checkout with `swift package edit ComposeKit --path +> ../ComposeKit`. Once tagged, bump the line above to that version and +> `swift package resolve`. + `Package.resolved` is **committed** (see `.gitignore`) so CI and release builds -pin the exact ComposeKit revision instead of floating to `main`'s HEAD. +pin the exact ComposeKit revision. **Local development against a ComposeKit working copy** — don't edit the line above; override it with an editable checkout: diff --git a/Package.swift b/Package.swift index 9389422..c7131df 100644 --- a/Package.swift +++ b/Package.swift @@ -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 @@ -17,8 +17,7 @@ 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 @@ -26,14 +25,30 @@ let package = Package( .package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.2"), ], 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")] + ), ] ) diff --git a/README.md b/README.md index d8d6409..caf8b4b 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/Sources/ContainerComposeKit/ContainerRunner.swift b/Sources/ContainerComposeKit/ContainerRunner.swift new file mode 100644 index 0000000..3db470e --- /dev/null +++ b/Sources/ContainerComposeKit/ContainerRunner.swift @@ -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)" + } + } + } +} diff --git a/Sources/ContainerComposeKit/ContainerTranslator.swift b/Sources/ContainerComposeKit/ContainerTranslator.swift new file mode 100644 index 0000000..6487d25 --- /dev/null +++ b/Sources/ContainerComposeKit/ContainerTranslator.swift @@ -0,0 +1,347 @@ +import ComposeKit +import Foundation + +/// Translates Compose `Service` definitions into Apple `container` CLI +/// argument vectors (`container run` and `container build`). +/// +/// This is where the runtime compatibility decisions live: anything Compose can +/// express but `container` cannot is either approximated here or skipped (with a +/// warning emitted by ``Orchestrator``). The translator is a pure value type — +/// it never touches the filesystem or spawns processes, so its output is fully +/// determined by its inputs and easy to test. +/// +/// All resources are project-scoped: a service named `web` in project `demo` +/// becomes the container `demo-web` on the `demo-default` network. +public struct ContainerTranslator: Sendable { + public let project: String + public let baseDirectory: URL + public let hostEnv: [String: String] + + /// Labels used to associate resources with this Compose project. + public static let projectLabel = "com.apple.container.compose.project" + public static let serviceLabel = "com.apple.container.compose.service" + public static let restartLabel = "com.apple.container.compose.restart" + + public init(project: String, baseDirectory: URL, hostEnv: [String: String]) { + self.project = project + self.baseDirectory = baseDirectory + self.hostEnv = hostEnv + } + + // MARK: - Resource naming (project-scoped) + + public func containerName(service: String, declared: String?) -> String { + declared ?? "\(project)-\(service)" + } + + public func networkName(_ name: String) -> String { "\(project)-\(name)" } + public func volumeName(_ name: String) -> String { "\(project)-\(name)" } + public var defaultNetwork: String { "\(project)-default" } + + /// Built image tag for a service that has `build` but no `image`. + public func builtImageTag(service: String) -> String { "\(project)-\(service):latest" } + + // MARK: - Build + + /// Build the `container build` argument vector for a service's `build:` block. + /// + /// - Parameters: + /// - service: the service name, used to derive the image tag when the + /// service has no explicit `image:`. + /// - svc: the service definition. Returns `nil` if it has no `build:`. + /// - resolvedSecrets: host paths for `build.secrets` sources, keyed by + /// source name (built by the ``Orchestrator``, as for run secrets). + /// - Returns: the argument vector following the `container` executable, or + /// `nil` when the service does not build an image. + public func buildArgs( + service: String, _ svc: Service, resolvedSecrets: [String: String] = [:] + ) -> [String]? { + guard let build = svc.build else { return nil } + let tag = svc.image ?? builtImageTag(service: service) + var a = ["build", "--tag", tag] + if let dockerfile = build.dockerfile { + a += ["--file", resolvePath(dockerfile, relativeTo: build.contextPath)] + } + if let target = build.target { + a += ["--target", target] + } + if let args = build.args { + for pair in args.pairs(hostEnv: hostEnv) { a += ["--build-arg", pair] } + } + if let long = build.long { + if long.no_cache == true { a += ["--no-cache"] } + if let labels = long.labels { + for pair in labels.pairs() { a += ["--label", pair] } + } + for ref in long.secrets?.sorted(by: { $0.source < $1.source }) ?? [] { + if let path = resolvedSecrets[ref.source] { + a += ["--secret", "id=\(ref.source),src=\(path)"] + } + } + } + a += [resolvePath(build.contextPath)] + return a + } + + // MARK: - Run + + /// Host paths for resolved config/secret sources, keyed by source name. + /// The Orchestrator builds this (resolving `file:` and materializing + /// `content:`/`environment:`); the translator stays a pure function of it. + public struct ResolvedFileObjects: Sendable, Equatable { + public var configs: [String: String] + public var secrets: [String: String] + public init(configs: [String: String] = [:], secrets: [String: String] = [:]) { + self.configs = configs + self.secrets = secrets + } + public static let none = ResolvedFileObjects() + } + + /// Build the `container run` argument vector for one service. + /// + /// - Parameters: + /// - name: the service name, used for the container name and labels. + /// - svc: the service definition to translate. + /// - image: the image to run (resolved by the caller — an explicit + /// `image:` or the tag produced by ``builtImageTag(service:)``). + /// - detach: run in the background (`--detach`). One-shot dependencies + /// gated by `service_completed_successfully` are run attached (`false`). + /// - files: resolved host paths for the service's configs/secrets. + /// - Returns: the argument vector following the `container` executable. + public func runArgs( + service name: String, _ svc: Service, image: String, + detach: Bool = true, files: ResolvedFileObjects = .none + ) -> [String] { + var a = ["run"] + if detach { a += ["--detach"] } + a += ["--name", containerName(service: name, declared: svc.container_name)] + + // Project bookkeeping labels. + a += ["--label", "\(Self.projectLabel)=\(project)"] + a += ["--label", "\(Self.serviceLabel)=\(name)"] + if let restart = svc.restart { + // UNSUPPORTED: no `--restart`; recorded as a label only (not enforced). + a += ["--label", "\(Self.restartLabel)=\(restart)"] + } + + // Environment. + if let env = svc.environment { + for pair in env.pairs(hostEnv: hostEnv) { a += ["--env", pair] } + } + if let envFiles = svc.env_file { + for f in envFiles.values { a += ["--env-file", resolvePath(f)] } + } + + // Ports (skipping forms container can't publish — see publishArgument). + for p in svc.ports ?? [] { + if let arg = publishArgument(p) { a += ["--publish", arg] } + } + + // Volumes / mounts. + if let vols = svc.volumes { + for v in vols { a += volumeArguments(v) } + } + + // Networks (attach to project default when none declared). + let nets = svc.networks?.names ?? [] + if nets.isEmpty { + a += ["--network", defaultNetwork] + } else { + for n in nets { a += ["--network", networkName(n)] } + } + + // User-defined labels. + if let labels = svc.labels { + for pair in labels.pairs() { a += ["--label", pair] } + } + + // Process / management flags. + if let cwd = svc.working_dir { a += ["--workdir", cwd] } + if let user = svc.user { a += ["--user", user] } + for cap in svc.cap_add ?? [] { a += ["--cap-add", cap] } + for cap in svc.cap_drop ?? [] { a += ["--cap-drop", cap] } + for d in svc.dns?.values ?? [] { a += ["--dns", d] } + for s in svc.dns_search?.values ?? [] { a += ["--dns-search", s] } + for o in svc.dns_opt ?? [] { a += ["--dns-option", o] } + for t in svc.tmpfs?.values ?? [] { a += ["--tmpfs", t] } + if svc.read_only == true { a += ["--read-only"] } + if svc.`init` == true { a += ["--init"] } + if let platform = svc.platform { a += ["--platform", platform] } + if let runtime = svc.runtime { a += ["--runtime", runtime] } + if let shm = svc.shm_size?.stringValue { a += ["--shm-size", shm] } + for u in ulimitArguments(svc.ulimits) { a += ["--ulimit", u] } + + // Interactive session: Compose `stdin_open` -> -i, `tty` -> -t. + if svc.stdin_open == true { a += ["--interactive"] } + if svc.tty == true { a += ["--tty"] } + + // Resource limits (deploy.resources.limits wins, then top-level shorthands). + if let raw = svc.deploy?.resources?.limits?.cpus?.stringValue ?? svc.cpus?.stringValue, + let cpus = Self.cpuCount(raw) + { + a += ["--cpus", cpus] + } + if let mem = svc.deploy?.resources?.limits?.memory ?? svc.mem_limit { + a += ["--memory", mem] + } + + // Configs / secrets: read-only file bind mounts. Secrets default to + // /run/secrets/, configs to /. + a += fileObjectMounts(svc.secrets, defaultDir: "/run/secrets", resolved: files.secrets) + a += fileObjectMounts(svc.configs, defaultDir: "", resolved: files.configs) + + // Entrypoint (container takes a single string; extra tokens go to command). + var trailing: [String] = [] + if let entrypoint = svc.entrypoint { + let values = entrypoint.values + if let first = values.first { + a += ["--entrypoint", first] + trailing += Array(values.dropFirst()) + } + } + + // Image, then command. + a += [image] + if let command = svc.command { + switch command { + case .list(let arr): + trailing += arr + case .string(let s): + // Compose string form runs through a shell. + trailing += ["/bin/sh", "-c", s] + } + } + a += trailing + return a + } + + // MARK: - Helpers + + /// Render a Compose port mapping as a `container --publish` argument, or + /// `nil` if `container` cannot publish it. + /// + /// `container` requires an explicit `host:container` port pair, so the + /// container-port-only short form (`"80"`, which Compose would assign an + /// ephemeral host port) is dropped — the ``Orchestrator`` warns instead. An + /// IPv6 `host_ip` is bracketed as the publish parser requires. + func publishArgument(_ port: PortMapping) -> String? { + switch port { + case .short(let s): + // Needs a host:container colon; a bare port/range can't be published. + return s.contains(":") ? s : nil + case .long(let p): + guard let published = p.published?.stringValue, !published.isEmpty else { return nil } + var arg = "" + if let host = p.host_ip { + arg += host.contains(":") ? "[\(host)]:" : "\(host):" + } + arg += "\(published):\(p.target)" + if let proto = p.`protocol` { arg += "/\(proto)" } + return arg + } + } + + /// Render `ulimits` as sorted `name=value` / `name=soft:hard` argument values. + private func ulimitArguments(_ ulimits: Ulimits?) -> [String] { + guard let ulimits else { return [] } + return ulimits.limits.sorted { $0.key < $1.key }.map { name, value in + switch value { + case .single(let v): return "\(name)=\(v)" + case .range(let soft, let hard): return "\(name)=\(soft):\(hard)" + } + } + } + + /// Map a Compose `cpus` value to `container --cpus`. + /// + /// Compose `cpus` is a fraction of CPU *time* (e.g. `0.5`), but Apple's + /// `container` expects an integer vCPU *count*. Round the request up to whole + /// vCPUs (minimum 1): `0.5 -> 1`, `1.5 -> 2`, `2 -> 2`. Returns `nil` for + /// non-positive or non-numeric values, so no `--cpus` flag is emitted. + static func cpuCount(_ raw: String) -> String? { + guard let value = Double(raw), value > 0 else { return nil } + return String(max(1, Int(value.rounded(.up)))) + } + + /// Read-only bind-mount args for a service's config/secret refs, sorted by + /// source name for deterministic output. Refs whose source wasn't resolved + /// (undefined, external, or unmaterialized) are skipped — the Orchestrator + /// warns about those. + private func fileObjectMounts( + _ refs: [ServiceFileRef]?, defaultDir: String, resolved: [String: String] + ) -> [String] { + guard let refs else { return [] } + var args: [String] = [] + for ref in refs.sorted(by: { $0.source < $1.source }) { + guard let host = resolved[ref.source] else { continue } + args += ["--volume", "\(host):\(mountTarget(ref, defaultDir: defaultDir)):ro"] + } + return args + } + + /// Resolve the in-container path for a config/secret ref. An absolute + /// `target` wins; a relative one is placed under `defaultDir` (or `/` for + /// configs); with no target, the source name is used. + private func mountTarget(_ ref: ServiceFileRef, defaultDir: String) -> String { + func place(_ leaf: String) -> String { + if leaf.hasPrefix("/") { return leaf } + return defaultDir.isEmpty ? "/\(leaf)" : "\(defaultDir)/\(leaf)" + } + if case .long(let l) = ref, let target = l.target { return place(target) } + return place(ref.source) + } + + private func volumeArguments(_ mount: VolumeMount) -> [String] { + switch mount { + case .short(let raw): + let parts = raw.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 2 else { + // Anonymous volume: just a container path. + return ["--volume", raw] + } + let source = resolveVolumeSource(parts[0]) + let rest = parts[1...].joined(separator: ":") + return ["--volume", "\(source):\(rest)"] + + case .long(let lv): + switch lv.type { + case "tmpfs": + return ["--tmpfs", lv.target] + case "bind": + var arg = "\(resolvePath(lv.source ?? ".")):\(lv.target)" + if lv.read_only == true { arg += ":ro" } + return ["--volume", arg] + default: // "volume" or unset + let source = lv.source.map { volumeName($0) } + var arg = source.map { "\($0):\(lv.target)" } ?? lv.target + if lv.read_only == true { arg += ":ro" } + return ["--volume", arg] + } + } + } + + /// A short-form volume source is a bind path if it looks like one, + /// otherwise a named (project-scoped) volume. + private func resolveVolumeSource(_ source: String) -> String { + if source.hasPrefix("/") || source.hasPrefix(".") || source.hasPrefix("~") { + return resolvePath(source) + } + return volumeName(source) + } + + /// Resolve a host path against the Compose file's directory (and `~`). + public func resolvePath(_ path: String, relativeTo subdir: String? = nil) -> String { + if path.hasPrefix("/") { return path } + if path.hasPrefix("~") { + return (path as NSString).expandingTildeInPath + } + var base = baseDirectory + if let subdir, !subdir.hasPrefix("/") { + base = base.appendingPathComponent(subdir) + } else if let subdir { + base = URL(fileURLWithPath: subdir) + } + return base.appendingPathComponent(path).standardizedFileURL.path + } +} diff --git a/Sources/ContainerComposeKit/HealthChecker.swift b/Sources/ContainerComposeKit/HealthChecker.swift new file mode 100644 index 0000000..20106bc --- /dev/null +++ b/Sources/ContainerComposeKit/HealthChecker.swift @@ -0,0 +1,106 @@ +import ComposeKit +import Foundation + +/// Polls a service's Compose `healthcheck` to implement +/// `depends_on: condition: service_healthy` gating. +/// +/// `container` has no native health check, so ComposeKit runs the Compose-defined +/// `healthcheck.test` itself via `container exec` in a poll loop, honoring +/// `interval`, `retries`, and `start_period`. +public struct HealthChecker: Sendable { + /// The runner used to exec the health-check command in the container. + public let runner: ContainerRunner + + /// Create a health checker that execs through `runner`. + public init(runner: ContainerRunner) { + self.runner = runner + } + + /// 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 { + guard let test = health.test, let execArgs = Self.execArguments(for: test) else { + return // no test / NONE => nothing to gate on + } + + let interval = Self.seconds(health.interval) ?? 30 + let startPeriod = Self.seconds(health.start_period) ?? 0 + let retries = max(health.retries ?? 3, 1) + + if startPeriod > 0, !runner.dryRun { + Thread.sleep(forTimeInterval: startPeriod) + } + + var attempt = 0 + while true { + attempt += 1 + let (status, _) = try runner.capture(["exec", container] + execArgs) + if status == 0 { return } + if attempt >= retries { throw ComposeError.dependencyUnhealthy(container) } + if !runner.dryRun { Thread.sleep(forTimeInterval: interval) } + } + } + + /// Translate a Compose `healthcheck.test` into `container exec` arguments. + /// Returns nil if the check is disabled (`["NONE"]`). + public static func execArguments(for test: StringOrList) -> [String]? { + switch test { + case .string(let s): + return ["/bin/sh", "-c", s] + case .list(let arr): + guard let head = arr.first else { return nil } + switch head { + case "NONE": + return nil + case "CMD": + return Array(arr.dropFirst()) + case "CMD-SHELL": + return ["/bin/sh", "-c", arr.dropFirst().joined(separator: " ")] + default: + return arr // bare argv form + } + } + } + + /// Parse a Go-style duration ("30s", "1m30s", "500ms", "1h") to seconds. + /// A bare number is treated as seconds. + static func seconds(_ text: String?) -> Double? { + guard let text, !text.isEmpty else { return nil } + if let plain = Double(text) { return plain } + + var total = 0.0 + var number = "" + var unit = "" + + func flush() -> Bool { + guard let value = Double(number) else { return false } + switch unit { + case "ns": total += value / 1_000_000_000 + case "us", "µs": total += value / 1_000_000 + case "ms": total += value / 1000 + case "s", "": total += value + case "m": total += value * 60 + case "h": total += value * 3600 + default: return false + } + number = "" + unit = "" + return true + } + + for ch in text { + if ch.isNumber || ch == "." { + if !unit.isEmpty { + if !flush() { return nil } + } + number.append(ch) + } else { + unit.append(ch) + } + } + if !number.isEmpty || !unit.isEmpty { + if !flush() { return nil } + } + return total + } +} diff --git a/Sources/ContainerComposeKit/Orchestrator.swift b/Sources/ContainerComposeKit/Orchestrator.swift new file mode 100644 index 0000000..2993266 --- /dev/null +++ b/Sources/ContainerComposeKit/Orchestrator.swift @@ -0,0 +1,482 @@ +import ComposeKit +import Foundation + +/// High-level Compose operations (`up`, `down`, `ps`, `logs`), built on top of +/// ``ContainerTranslator`` and ``ContainerRunner``. +/// +/// The orchestrator owns the side effects the translator avoids: it creates +/// project networks and volumes, materializes `content:`/`environment:` +/// configs and secrets, gates `depends_on` conditions, and spawns `container` +/// commands in dependency order. Construct one from a loaded `Project`. +public struct Orchestrator: Sendable { + /// The loaded project being operated on. + public let project: Project + /// The runner used to invoke the `container` CLI. + public let runner: ContainerRunner + /// The translator mapping services to `container` argument vectors. + public let translator: ContainerTranslator + + /// Create an orchestrator for a loaded `Project`, wiring a + /// ``ContainerTranslator`` from the project's identity and variables. + public init(project: Project, runner: ContainerRunner) { + self.project = project + self.runner = runner + self.translator = ContainerTranslator( + project: project.name, + baseDirectory: project.baseDirectory, + hostEnv: project.variables + ) + } + + private func warn(_ message: String) { + FileHandle.standardError.write(Data("container-compose: warning: \(message)\n".utf8)) + } + + private func info(_ message: String) { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + } + + // MARK: - up + + /// Create and start the project's services in dependency order. + /// + /// Provisions networks and volumes, resolves configs/secrets, honors + /// `depends_on` conditions (waiting on `service_healthy`, running + /// `service_completed_successfully` dependencies to completion), and recreates + /// any stale containers. + /// + /// - Parameters: + /// - build: force a `container build` for services that have a `build:` + /// section, even when an `image:` already exists. + /// - services: limit the operation to these services (and their + /// 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]) throws { + try validate(services) + let selected = project.enabledServices(explicit: services) + try ensureNetworks() + try ensureVolumes() + + // Services that a selected service depends on with + // `condition: service_completed_successfully` — run these to completion. + let oneShot = completedSuccessfullyTargets(selected: selected) + let resolved = resolveFileObjects(selected: selected) + + 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( + 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) + + // 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]) + + // One-shot dependency: run attached and use the exit code as the gate + // (`container` has no `wait`); a non-zero exit aborts `up`. Topological + // order guarantees it completes before any dependent starts. + if oneShot.contains(name) { + info("Running \(cname) (one-shot) ...") + let args = translator.runArgs( + service: name, svc, image: image, detach: false, files: resolved) + let status = try runner.run(args) + if status != 0 { throw ComposeError.dependencyFailed(name, status) } + } else { + info("Creating \(cname) ...") + try runner.runChecked( + translator.runArgs(service: name, svc, image: image, files: resolved)) + } + } + } + + /// Names of services that any selected service depends on with the + /// `service_completed_successfully` condition. + private func completedSuccessfullyTargets(selected: Set) -> Set { + var targets = Set() + for name in selected { + guard let deps = project.file.services[name]?.depends_on else { continue } + for dep in deps.names + where selected.contains(dep) + && deps.condition(for: dep) == "service_completed_successfully" { + targets.insert(dep) + } + } + return targets + } + + /// Block on `service_healthy` dependencies that were also selected for `up`. + private func gateDependencies(of name: String, _ svc: Service, selected: Set) 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 } + 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 + { + info("Waiting for '\(dep)' to be healthy ...") + try HealthChecker(runner: runner).waitHealthy(container: depContainer, health: hc) + } else { + warn( + "service '\(name)': depends_on '\(dep)' wants service_healthy but " + + "'\(dep)' defines no healthcheck; starting without gating") + } + } + } + + /// Resolve every config/secret source referenced by a selected service to a + /// host file path: `file:` is resolved against the project dir; + /// `content:`/`environment:` are materialized to a temp file. `external:`, + /// undefined, and source-less entries are warned and skipped, as are + /// uid/gid/mode (bind mounts can't enforce them). + private func resolveFileObjects(selected: Set) -> ContainerTranslator.ResolvedFileObjects { + var configs: [String: String] = [:] + var secrets: [String: String] = [:] + + func resolve( + kind: String, + defs: [String: FileObjectSpec?]?, + refsFor: (Service) -> [ServiceFileRef]?, + into out: inout [String: String] + ) { + var referenced = Set() + for name in selected { + guard let svc = project.file.services[name] else { continue } + for ref in refsFor(svc) ?? [] { + referenced.insert(ref.source) + if case .long(let l) = ref, l.uid != nil || l.gid != nil || l.mode != nil { + warn("service '\(name)': \(kind) '\(ref.source)' uid/gid/mode " + + "are not enforced by container") + } + } + } + for source in referenced.sorted() { + guard let spec = defs?[source] ?? nil else { + warn("\(kind) '\(source)' is referenced but not defined; skipping") + continue + } + if spec.external?.isExternal == true { + warn("\(kind) '\(source)' is external; container cannot resolve it, skipping") + } else if let file = spec.file { + out[source] = translator.resolvePath(file) + } else if let content = spec.content { + if let p = materialize(kind: kind, name: source, content: content) { out[source] = p } + } else if let envName = spec.environment { + let value = project.variables[envName] ?? "" + if let p = materialize(kind: kind, name: source, content: value) { out[source] = p } + } else { + warn("\(kind) '\(source)' has no file/content/environment source; skipping") + } + } + } + + // Secret sources come from both service `secrets:` and `build.secrets`. + resolve( + kind: "secret", defs: project.file.secrets, + refsFor: { ($0.secrets ?? []) + ($0.build?.long?.secrets ?? []) }, into: &secrets) + resolve(kind: "config", defs: project.file.configs, refsFor: { $0.configs }, into: &configs) + return .init(configs: configs, secrets: secrets) + } + + /// Write inline `content:`/`environment:` source data to a temp file and + /// return its path. During a dry run nothing is written (the path is still + /// returned so the planned mount is visible). + private func materialize(kind: String, name: String, content: String) -> String? { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("composekit-\(project.name)", isDirectory: true) + .appendingPathComponent("\(kind)s", isDirectory: true) + let url = dir.appendingPathComponent(name) + if runner.dryRun { return url.path } + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try content.write(to: url, atomically: true, encoding: .utf8) + return url.path + } catch { + warn("\(kind) '\(name)': failed to materialize content (\(error)); skipping") + return nil + } + } + + private func imageForService( + name: String, svc: Service, forceBuild: Bool, buildSecrets: [String: String] + ) 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) + } + return svc.image ?? translator.builtImageTag(service: name) + } + guard let image = svc.image else { + throw ComposeError.serviceMissingImage(name) + } + return image + } + + // MARK: - down + + /// Stop and remove the project's containers and networks (in reverse + /// dependency order), optionally removing named volumes too. + /// + /// - Parameter removeVolumes: also delete the project's declared named + /// volumes (data loss); defaults to keeping them. + public func down(removeVolumes: Bool) 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]) + } + + // 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]) + } + + if removeVolumes { + for volName in (project.file.volumes ?? [:]).keys { + runner.runSilently(["volume", "delete", translator.volumeName(volName)]) + } + } + } + + // MARK: - ps + + /// Print the project's containers, filtered from `container list` by name + /// 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 { + var args = ["list"] + if all { args.append("--all") } + let (status, out) = try runner.capture(args) + guard status == 0 else { throw ContainerRunner.RunnerError.nonZeroExit(command: args, status: status) } + + let prefix = "\(project.name)-" + let declaredNames = Set( + project.file.services.map { name, svc in + translator.containerName(service: name, declared: svc.container_name) + } + ) + let lines = out.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + guard let header = lines.first else { return } + print(header) + for line in lines.dropFirst() { + let belongs = line.contains(prefix) || declaredNames.contains { line.contains($0) } + if belongs { print(line) } + } + } + + // MARK: - logs + + /// Print logs for the selected services (all of them when `services` is empty). + /// + /// - Parameters: + /// - follow: stream new output (`container logs --follow`). With more than + /// 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, only services: [String]) 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") + } + for name in selected { + guard let svc = project.file.services[name] else { continue } + let cname = translator.containerName(service: name, declared: svc.container_name) + var args = ["logs"] + if follow { args.append("--follow") } + args.append(cname) + _ = try? runner.run(args) + } + } + + // MARK: - exec + + /// Run a command inside a running service's container, inheriting stdio so + /// interactive shells work. + /// + /// - Parameters: + /// - service: the service whose container to exec into. + /// - command: the command and arguments to run. + /// - interactive: keep stdin open (`-i`). + /// - tty: allocate a TTY (`-t`). + /// - Returns: the command's exit status. + /// - Throws: `ComposeError.unknownService` if the service is undeclared. + @discardableResult + public func exec( + service: String, command: [String], interactive: Bool = false, tty: Bool = false + ) throws -> Int32 { + try validate([service]) + let svc = project.file.services[service] + let cname = translator.containerName(service: service, declared: svc?.container_name) + var args = ["exec"] + if interactive { args.append("--interactive") } + if tty { args.append("--tty") } + args.append(cname) + args += command + return try runner.run(args) + } + + // MARK: - pull + + /// Pre-fetch images for the selected services. Build-only services (no + /// `image:`) and duplicate image references are skipped. + /// + /// - Parameter services: limit to these services; empty means all. + public func pull(only services: [String]) throws { + let selected = try select(services) + var pulled = Set() + for name in selected.sorted() { + guard let svc = project.file.services[name], let image = svc.image, svc.build == nil + else { continue } + guard pulled.insert(image).inserted else { continue } + info("Pulling \(image) ...") + try runner.runChecked(["image", "pull", image]) + } + } + + // MARK: - stop / start / restart + + /// 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 { + 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]) + } + } + + /// Start previously-created containers for the selected services (dependency + /// order) without recreating them. + public func start(only services: [String]) 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]) + } + } + + /// 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) + } + + // MARK: - shared helpers + + private func select(_ services: [String]) throws -> Set { + guard !services.isEmpty else { return Set(project.file.services.keys) } + try validate(services) + return Set(services) + } + + /// Throw on any name that isn't a declared service. + private func validate(_ services: [String]) throws { + for s in services where project.file.services[s] == nil { + throw ComposeError.unknownService(s) + } + } + + /// Selected services in dependency order (optionally reversed). + private func ordered(_ services: [String], reversed: Bool) throws -> [String] { + let selected = try select(services) + let order = try Planner.startOrder(project.file.services).filter { selected.contains($0) } + return reversed ? order.reversed() : order + } + + private func ensureNetworks() throws { + var toCreate: [(name: String, spec: NetworkSpec?)] = [(translator.defaultNetwork, nil)] + for (declared, spec) in project.file.networks ?? [:] { + // External networks are assumed to already exist. + if spec?.external?.isExternal == true { continue } + toCreate.append((translator.networkName(declared), spec)) + } + for (name, spec) in toCreate { + var args = ["network", "create"] + if spec?.`internal` == true { args.append("--internal") } + if let subnet = spec?.subnet { args += ["--subnet", subnet] } + 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) + } + } + + private func ensureVolumes() throws { + // Declared top-level named volumes. + var names = Set() + for (declared, spec) in project.file.volumes ?? [:] { + if spec?.external?.isExternal == true { continue } + names.insert(declared) + } + for name in names.sorted() { + // Idempotent: an existing volume is reused. + runner.runSilently(["volume", "create", translator.volumeName(name)]) + } + } + + private func warnUnsupported(name: String, svc: Service) { + if svc.restart != nil { + warn("service '\(name)': 'restart' is recorded as a label but not enforced by container") + } + if svc.privileged == true { + warn("service '\(name)': 'privileged' has no container equivalent and is ignored") + } + // Popular fields `container` cannot express — decoded so the file parses, + // but flagged so the gap is visible rather than silently dropped. + var ignored: [String] = [] + if svc.hostname != nil { ignored.append("hostname") } + if svc.extra_hosts != nil { ignored.append("extra_hosts") } + if svc.network_mode != nil { ignored.append("network_mode") } + if svc.devices != nil { ignored.append("devices") } + if svc.sysctls != nil { ignored.append("sysctls") } + if svc.security_opt != nil { ignored.append("security_opt") } + if svc.stop_signal != nil { ignored.append("stop_signal") } + if svc.stop_grace_period != nil { ignored.append("stop_grace_period") } + if svc.gpus != nil { ignored.append("gpus") } + if !ignored.isEmpty { + warn("service '\(name)': \(ignored.joined(separator: ", ")) " + + "\(ignored.count == 1 ? "has" : "have") no container equivalent and " + + "\(ignored.count == 1 ? "is" : "are") ignored") + } + + // Ports container can't publish (container-port-only; no host port). + for port in svc.ports ?? [] where translator.publishArgument(port) == nil { + let text: String + switch port { + case .short(let s): text = s + case .long(let p): text = String(p.target) + } + warn("service '\(name)': port '\(text)' publishes only a container port; " + + "container cannot auto-assign a host port, skipping") + } + + // Build fields `container build` cannot express. + var build: [String] = [] + if svc.build?.long?.ssh != nil { build.append("build.ssh") } + if svc.build?.long?.network != nil { build.append("build.network") } + if svc.build?.long?.cache_from != nil { build.append("build.cache_from") } + if !build.isEmpty { + warn("service '\(name)': \(build.joined(separator: ", ")) " + + "\(build.count == 1 ? "is" : "are") not supported by container build and " + + "\(build.count == 1 ? "is" : "are") ignored") + } + } +} diff --git a/Sources/container-compose/Commands.swift b/Sources/container-compose/Commands.swift index c03f4ed..46b00ce 100644 --- a/Sources/container-compose/Commands.swift +++ b/Sources/container-compose/Commands.swift @@ -4,7 +4,7 @@ import ArgumentParser import ComposeKit -import ComposeKitContainer +import ContainerComposeKit import Foundation struct Up: AsyncParsableCommand { diff --git a/Sources/container-compose/ContainerCompose.swift b/Sources/container-compose/ContainerCompose.swift index 7bc2c70..2b7f7d9 100644 --- a/Sources/container-compose/ContainerCompose.swift +++ b/Sources/container-compose/ContainerCompose.swift @@ -7,7 +7,7 @@ import ArgumentParser import ComposeKit -import ComposeKitContainer +import ContainerComposeKit import Foundation @main diff --git a/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift new file mode 100644 index 0000000..6edf6d6 --- /dev/null +++ b/Tests/ContainerComposeKitTests/ContainerComposeKitTests.swift @@ -0,0 +1,400 @@ +import Foundation +import Testing + +import ComposeKit +@testable import ContainerComposeKit + +private func loadFixture() throws -> Project { + let url = Bundle.module.url(forResource: "compose", withExtension: "yaml", subdirectory: "Fixtures")! + let dir = url.deletingLastPathComponent() + return try Project.load(explicit: url.path, projectName: nil, cwd: dir) +} + +@Suite("Translation") +struct TranslationTests { + private func translator() -> ContainerTranslator { + ContainerTranslator(project: "demo", baseDirectory: URL(fileURLWithPath: "/proj"), hostEnv: [:]) + } + + @Test("run args carry name, labels, network, ports") + func runArgs() throws { + let project = try loadFixture() + let web = project.file.services["web"]! + let args = translator().runArgs(service: "web", web, image: "demo-web:latest") + #expect(args.contains("run")) + #expect(args.contains("--detach")) + #expect(adjacent(args, "--name", "demo-web")) + #expect(adjacent(args, "--publish", "8080:80")) + #expect(adjacent(args, "--network", "demo-backend")) + #expect(adjacent(args, "--network", "demo-frontend")) + #expect(adjacent(args, "--cpus", "1")) // fractional 0.5 -> 1 vCPU + #expect(adjacent(args, "--memory", "256m")) + // image precedes the command + let imageIdx = args.firstIndex(of: "demo-web:latest")! + let nodeIdx = args.firstIndex(of: "node")! + #expect(imageIdx < nodeIdx) + } + + @Test("named volume source is project-scoped, bind path is resolved") + func volumes() throws { + let project = try loadFixture() + let db = project.file.services["db"]! + let args = translator().runArgs(service: "db", db, image: "postgres:16") + #expect(adjacent(args, "--volume", "demo-dbdata:/var/lib/postgresql/data")) + + let web = project.file.services["web"]! + let webArgs = translator().runArgs(service: "web", web, image: "demo-web:latest") + #expect(webArgs.contains { $0.hasSuffix("/proj/web/static:/app/static:ro") }) + } + + @Test("short string command runs through a shell") + func shellCommand() throws { + let yaml = """ + services: + x: + image: alpine + command: echo hello + """ + let file = try ComposeFile.parse(yaml: yaml) + let args = translator().runArgs(service: "x", file.services["x"]!, image: "alpine") + #expect(adjacent(args, "/bin/sh", "-c")) + #expect(args.last == "echo hello") + } + + @Test("fractional cpus are rounded up to whole vCPUs for container --cpus") + func cpuCount() { + #expect(ContainerTranslator.cpuCount("0.5") == "1") + #expect(ContainerTranslator.cpuCount("1.5") == "2") + #expect(ContainerTranslator.cpuCount("2") == "2") + #expect(ContainerTranslator.cpuCount("4") == "4") + #expect(ContainerTranslator.cpuCount("0") == nil) + #expect(ContainerTranslator.cpuCount("abc") == nil) + } +} + +@Suite("Health") +struct HealthTests { + @Test("duration parsing") + func durations() { + #expect(HealthChecker.seconds("30s") == 30) + #expect(HealthChecker.seconds("1m30s") == 90) + #expect(HealthChecker.seconds("500ms") == 0.5) + #expect(HealthChecker.seconds("2") == 2) + #expect(HealthChecker.seconds(nil) == nil) + } + + @Test("test translation: CMD, CMD-SHELL, NONE, string") + func translation() { + #expect(HealthChecker.execArguments(for: .list(["CMD", "curl", "-f", "x"])) == ["curl", "-f", "x"]) + #expect(HealthChecker.execArguments(for: .list(["CMD-SHELL", "curl -f x"])) == ["/bin/sh", "-c", "curl -f x"]) + #expect(HealthChecker.execArguments(for: .list(["NONE"])) == nil) + #expect(HealthChecker.execArguments(for: .string("pg_isready")) == ["/bin/sh", "-c", "pg_isready"]) + } + + @Test("depends_on condition is read") + func condition() throws { + let yaml = """ + services: + web: + image: x + depends_on: + db: + condition: service_healthy + db: + image: y + """ + let file = try ComposeFile.parse(yaml: yaml) + #expect(file.services["web"]?.depends_on?.condition(for: "db") == "service_healthy") + } +} + +@Suite("New-field translation") +struct NewFieldTests { + private func worker() throws -> Service { + let url = Bundle.module.url( + forResource: "resources", withExtension: "yaml", subdirectory: "Fixtures/corpus")! + return try ComposeFile.parse(yaml: String(contentsOf: url, encoding: .utf8)) + .services["worker"]! + } + + @Test("ulimits, shm_size, dns, runtime, tty translate to container flags") + func translates() throws { + let t = ContainerTranslator( + project: "p", baseDirectory: URL(fileURLWithPath: "/p"), hostEnv: [:]) + let args = t.runArgs(service: "worker", try worker(), image: "app:latest") + #expect(adjacent(args, "--ulimit", "nofile=1024:524288")) + #expect(adjacent(args, "--ulimit", "nproc=65535")) + #expect(adjacent(args, "--shm-size", "128m")) + #expect(adjacent(args, "--runtime", "runc")) + #expect(adjacent(args, "--dns-search", "corp.example.com")) + #expect(adjacent(args, "--dns-option", "timeout:2")) + #expect(args.contains("--interactive")) + #expect(args.contains("--tty")) + } + + @Test("extra_hosts normalizes list and map forms to host:ip") + func extraHosts() { + #expect(ExtraHosts.list(["a:1.1.1.1"]).entries == ["a:1.1.1.1"]) + #expect( + ExtraHosts.map(["b": ComposeScalar("2.2.2.2"), "a": ComposeScalar("1.1.1.1")]).entries + == ["a:1.1.1.1", "b:2.2.2.2"]) + } +} + +@Suite("Configs & secrets") +struct FileObjectTests { + private func appService() throws -> Service { + let url = Bundle.module.url( + forResource: "configs-secrets", withExtension: "yaml", subdirectory: "Fixtures/corpus")! + return try ComposeFile.parse(yaml: String(contentsOf: url, encoding: .utf8)).services["app"]! + } + + @Test("short and long refs parse") + func parseRefs() throws { + let app = try appService() + #expect(app.secrets?.map(\.source).sorted() == ["api_key", "db_password"]) + #expect(app.configs?.map(\.source).sorted() == ["app_config", "nginx_conf"]) + } + + @Test("configs/secrets become read-only bind mounts at the right targets") + func mounts() throws { + let t = ContainerTranslator( + project: "p", baseDirectory: URL(fileURLWithPath: "/p"), hostEnv: [:]) + let files = ContainerTranslator.ResolvedFileObjects( + configs: ["app_config": "/h/app.yaml", "nginx_conf": "/h/nginx.conf"], + secrets: ["db_password": "/h/db", "api_key": "/h/api"]) + let args = t.runArgs(service: "app", try appService(), image: "app:latest", files: files) + #expect(adjacent(args, "--volume", "/h/db:/run/secrets/db_password:ro")) // short secret default + #expect(adjacent(args, "--volume", "/h/api:/etc/api/key:ro")) // long secret custom target + #expect(adjacent(args, "--volume", "/h/app.yaml:/etc/app/config.yaml:ro")) // long config target + #expect(adjacent(args, "--volume", "/h/nginx.conf:/nginx_conf:ro")) // short config -> /name + // Mounts precede the image. + let vol = args.firstIndex(of: "/h/db:/run/secrets/db_password:ro")! + #expect(vol < args.firstIndex(of: "app:latest")!) + } + + @Test("detach:false omits --detach for one-shot runs") + func detachFlag() throws { + let t = ContainerTranslator( + project: "p", baseDirectory: URL(fileURLWithPath: "/p"), hostEnv: [:]) + let svc = try ComposeFile.parse(yaml: "services:\n x:\n image: a\n").services["x"]! + #expect(!t.runArgs(service: "x", svc, image: "a", detach: false).contains("--detach")) + #expect(t.runArgs(service: "x", svc, image: "a").contains("--detach")) + } +} + +// .serialized: these mutate the process-global CONTAINER_CLI env var. + +@Suite("Completed-successfully gating", .serialized) +struct OneShotTests { + @Test("dry-run up completes for a stack using service_completed_successfully") + func dryRunUp() 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( + forResource: "local-dev", withExtension: "yaml", subdirectory: "Fixtures/corpus")! + 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: []) + } + + @Test("a failing one-shot dependency aborts up") + func failingOneShotAborts() 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 + .appendingPathComponent("container-fail-shim-\(getpid())") + 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 yaml = """ + services: + a: + image: alpine + command: ["false"] + b: + image: alpine + depends_on: + a: + condition: service_completed_successfully + """ + let project = Project( + name: "t", file: try ComposeFile.parse(yaml: yaml), + baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) + let orch = Orchestrator(project: project, runner: runner) + + do { + try orch.up(build: false, only: []) + Issue.record("expected up to throw dependencyFailed") + } catch let ComposeError.dependencyFailed(name, status) { + #expect(name == "a") + #expect(status == 7) + } + } +} + +@Suite("Build translation") +struct BuildTests { + @Test("advanced build fields translate to container build flags") + func buildArgs() throws { + let url = Bundle.module.url( + forResource: "build-advanced", withExtension: "yaml", subdirectory: "Fixtures/corpus")! + let svc = try ComposeFile.parse(yaml: String(contentsOf: url, encoding: .utf8)) + .services["app"]! + let t = ContainerTranslator( + project: "p", baseDirectory: URL(fileURLWithPath: "/p"), hostEnv: [:]) + let args = t.buildArgs(service: "app", svc, resolvedSecrets: ["build_token": "/h/tok"])! + #expect(args.contains("--no-cache")) + #expect(adjacent(args, "--label", "com.example.tier=web")) + #expect(adjacent(args, "--secret", "id=build_token,src=/h/tok")) + } +} + +// .serialized: mutates the process-global CONTAINER_CLI env var. + +@Suite("Lifecycle commands", .serialized) +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) + 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 yaml = """ + services: + db: + image: postgres:16 + app: + build: . + depends_on: [db] + """ + let project = Project( + name: "proj", file: try ComposeFile.parse(yaml: yaml), + baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) + try 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) + } + #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: []) } + #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: []) } + 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: []) } + #expect(lines.contains("stop proj-db")) + #expect(lines.contains("start proj-db")) + let lastStop = lines.lastIndex { $0.hasPrefix("stop ") }! + let firstStart = lines.firstIndex { $0.hasPrefix("start ") }! + #expect(lastStop < firstStart) + } + + @Test("down stops in reverse order, removes the network, keeps volumes") + func down() throws { + let lines = try capture("down") { try $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 + #expect(lines.contains("network delete proj-default")) + #expect(!lines.contains { $0.hasPrefix("volume delete") }) + } +} + +@Suite("Port publishing") +struct PortPublishTests { + private func translator() -> ContainerTranslator { + ContainerTranslator(project: "p", baseDirectory: URL(fileURLWithPath: "/p"), hostEnv: [:]) + } + private func service(_ ports: String) throws -> Service { + try ComposeFile.parse(yaml: "services:\n s:\n image: x\n ports:\n\(ports)").services["s"]! + } + + @Test("a bare container port is not published (container can't auto-assign)") + func barePort() throws { + let args = translator().runArgs(service: "s", try service(" - \"80\"\n"), image: "x") + #expect(!args.contains("--publish")) + } + + @Test("a host:container port is published verbatim") + func hostPort() throws { + let args = translator().runArgs(service: "s", try service(" - \"8080:80\"\n"), image: "x") + #expect(adjacent(args, "--publish", "8080:80")) + } + + @Test("an IPv6 host_ip is bracketed") + func ipv6() throws { + let svc = try service(" - host_ip: \"::1\"\n published: 8080\n target: 80\n") + #expect(adjacent(translator().runArgs(service: "s", svc, image: "x"), "--publish", "[::1]:8080:80")) + } +} + +@Suite("Orchestrator error paths") +struct OrchestratorErrorTests { + private func project(_ yaml: String) throws -> Project { + Project( + name: "p", file: try ComposeFile.parse(yaml: yaml), + baseDirectory: URL(fileURLWithPath: "/tmp"), variables: [:]) + } + + @Test("up on a service with neither image nor build throws serviceMissingImage") + func missingImage() 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: []) } + } + + @Test("exec on an unknown service throws unknownService") + func unknownExec() 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"]) } + } +} + +/// True if `value` immediately follows `flag` somewhere in `args`. +private func adjacent(_ args: [String], _ flag: String, _ value: String) -> Bool { + for i in args.indices.dropLast() where args[i] == flag && args[i + 1] == value { + return true + } + return false +} diff --git a/Tests/ContainerComposeKitTests/Fixtures/compose.yaml b/Tests/ContainerComposeKitTests/Fixtures/compose.yaml new file mode 100644 index 0000000..44f18ef --- /dev/null +++ b/Tests/ContainerComposeKitTests/Fixtures/compose.yaml @@ -0,0 +1,55 @@ +name: demo +services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: secret + POSTGRES_USER: app + volumes: + - dbdata:/var/lib/postgresql/data + networks: + - backend + + cache: + image: redis:7 + networks: + - backend + + web: + build: + context: ./web + dockerfile: Dockerfile + args: + - BUILD_ENV=prod + command: ["node", "server.js"] + ports: + - "8080:80" + - "127.0.0.1:9090:9090/tcp" + environment: + - DATABASE_URL=postgres://app@db:5432/app + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + volumes: + - ./web/static:/app/static:ro + networks: + - backend + - frontend + restart: unless-stopped + deploy: + resources: + limits: + cpus: "0.5" + memory: 256m + +networks: + backend: + frontend: + ipam: + config: + - subnet: 10.5.0.0/24 + +volumes: + dbdata: diff --git a/Tests/ContainerComposeKitTests/Fixtures/corpus/build-advanced.yaml b/Tests/ContainerComposeKitTests/Fixtures/corpus/build-advanced.yaml new file mode 100644 index 0000000..04a8283 --- /dev/null +++ b/Tests/ContainerComposeKitTests/Fixtures/corpus/build-advanced.yaml @@ -0,0 +1,23 @@ +# Advanced build fields. no_cache/labels/secrets translate to container build +# flags; ssh/network/cache_from are decoded but warned (no container build flag). +name: buildadv +services: + app: + image: app:latest + build: + context: . + dockerfile: Dockerfile.app + no_cache: true + labels: + - "com.example.tier=web" + secrets: + - build_token + ssh: + - default + network: host + cache_from: + - app:cache + +secrets: + build_token: + file: ./secrets/api_key.txt diff --git a/Tests/ContainerComposeKitTests/Fixtures/corpus/configs-secrets.yaml b/Tests/ContainerComposeKitTests/Fixtures/corpus/configs-secrets.yaml new file mode 100644 index 0000000..278f644 --- /dev/null +++ b/Tests/ContainerComposeKitTests/Fixtures/corpus/configs-secrets.yaml @@ -0,0 +1,30 @@ +# configs/secrets provisioning: short + long refs, content: and file: sources. +# content: sources need no backing file (safe for docker compose config); +# the one file: source has a committed backing file under secrets/. +name: confsec +services: + app: + image: app:latest + secrets: + - db_password # short -> /run/secrets/db_password + - source: api_key # long -> custom target + target: /etc/api/key + mode: 0440 + configs: + - source: app_config # long -> custom target + target: /etc/app/config.yaml + - nginx_conf # short -> /nginx_conf + +secrets: + db_password: + environment: DB_PASSWORD # materialized from the env var + api_key: + file: ./secrets/api_key.txt + +configs: + app_config: + content: | + log_level: debug + workers: 4 + nginx_conf: + content: "worker_processes 1;" diff --git a/Tests/ContainerComposeKitTests/Fixtures/corpus/local-dev.yaml b/Tests/ContainerComposeKitTests/Fixtures/corpus/local-dev.yaml new file mode 100644 index 0000000..159e6e5 --- /dev/null +++ b/Tests/ContainerComposeKitTests/Fixtures/corpus/local-dev.yaml @@ -0,0 +1,64 @@ +# A typical local-dev stack: web + db + cache + one-shot migration, wired with +# depends_on conditions and a healthcheck-gated database. +name: localdev +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: secret + POSTGRES_DB: app + volumes: + - dbdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 3s + retries: 5 + start_period: 10s + networks: + - backend + + cache: + image: redis:7 + command: ["redis-server", "--save", "", "--appendonly", "no"] + networks: + - backend + + migrate: + image: app:latest + command: ["./migrate", "up"] + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgres://app:secret@db:5432/app + networks: + - backend + + web: + image: app:latest + command: ["./serve"] + ports: + - "8080:8080" + environment: + DATABASE_URL: postgres://app:secret@db:5432/app + REDIS_URL: redis://cache:6379 + depends_on: + db: + condition: service_healthy + cache: + condition: service_started + migrate: + condition: service_completed_successfully + restart: unless-stopped + networks: + - backend + - frontend + +networks: + backend: + frontend: + +volumes: + dbdata: diff --git a/Tests/ContainerComposeKitTests/Fixtures/corpus/resources.yaml b/Tests/ContainerComposeKitTests/Fixtures/corpus/resources.yaml new file mode 100644 index 0000000..4480f20 --- /dev/null +++ b/Tests/ContainerComposeKitTests/Fixtures/corpus/resources.yaml @@ -0,0 +1,32 @@ +# Fields that ComposeKit translates onto `container run`: ulimits, shm_size, +# dns options, runtime, tty/stdin, and deploy resource limits. +name: resources +services: + worker: + image: app:latest + tty: true + stdin_open: true + runtime: runc + shm_size: 128m + ulimits: + nproc: 65535 + nofile: + soft: 1024 + hard: 524288 + dns: + - 1.1.1.1 + dns_search: + - corp.example.com + dns_opt: + - timeout:2 + - attempts:3 + deploy: + resources: + limits: + cpus: "1.5" + memory: 512m + networks: + - default + +networks: + default: From 54c483b54f51fbcb837e5b3ff9ac4b88bfcc22a9 Mon Sep 17 00:00:00 2001 From: Denis Panfilov Date: Sun, 21 Jun 2026 15:05:23 +0200 Subject: [PATCH 2/2] Pin ComposeKit 0.0.3 (runtime layer extracted); build standalone ComposeKit 0.0.3 removed the ComposeKitContainer product; this repo now owns the runtime layer (ContainerComposeKit) and depends only on ComposeKit's core product. Builds and tests without a local swift-package-edit checkout. --- PACKAGING.md | 15 +++++---------- Package.resolved | 6 +++--- Package.swift | 2 +- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/PACKAGING.md b/PACKAGING.md index 6c3b8fd..7704ff8 100644 --- a/PACKAGING.md +++ b/PACKAGING.md @@ -19,18 +19,13 @@ 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", exact: "0.0.2"), +.package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"), ``` -> **Pending bump:** the runtime layer was extracted from ComposeKit, so this repo -> now requires the ComposeKit release that removed the `ComposeKitContainer` -> product (the next tag after `0.0.2`, e.g. `0.0.3`). Until that tag exists, -> build against a local checkout with `swift package edit ComposeKit --path -> ../ComposeKit`. Once tagged, bump the line above to that version and -> `swift package resolve`. - -`Package.resolved` is **committed** (see `.gitignore`) so CI and release builds -pin the exact ComposeKit revision. +`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: diff --git a/Package.resolved b/Package.resolved index 31a37e8..2e84fc4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "9d0e2d058da2319e03c8842735abcc9d2ae0880c2af840a388f748397f310fda", + "originHash" : "b00ccfe3bedf2001e50feb7651d12aeb65675a4a67face1d01831f1a03f79097", "pins" : [ { "identity" : "composekit", "kind" : "remoteSourceControl", "location" : "https://github.com/flaticols/ComposeKit.git", "state" : { - "revision" : "95ed40fe23e742746e8bb3f193351caeebe43b84", - "version" : "0.0.2" + "revision" : "bebaef274da3123db3c2f405dbbaf2e04d9486ad", + "version" : "0.0.3" } }, { diff --git a/Package.swift b/Package.swift index c7131df..54f8b3f 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,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.2"), + .package(url: "https://github.com/flaticols/ComposeKit.git", exact: "0.0.3"), ], targets: [ // The `container` runtime layer: maps the parsed model onto `container`