Files
applecontainercompose/Sources/ComposeCore.swift
T
2026-11-09 08:20:57 +00:00

456 lines
29 KiB
Swift

import Foundation
import Yams
import Darwin
// Signal handlers only enqueue cancellation. Runtime stop commands run on the caller.
private final class ForegroundInterrupt {
private let lock = NSLock()
private var cancelled = false
private var sources: [DispatchSourceSignal] = []
private var previous: [(Int32, sig_t?)] = []
var interrupted: Bool { lock.lock(); defer { lock.unlock() }; return cancelled }
init() {
for number in [SIGINT, SIGTERM] {
previous.append((number, signal(number, { _ in })))
let source = DispatchSource.makeSignalSource(signal: number, queue: .global())
source.setEventHandler { [weak self] in
guard let self else { return }
self.lock.lock(); self.cancelled = true; self.lock.unlock()
}
source.resume()
sources.append(source)
}
}
deinit {
for source in sources { source.cancel() }
for (number, handler) in previous { signal(number, handler) }
}
}
public struct ComposeService: Identifiable {
public let id: String
public var image: String
public var buildContext: String?
public var dockerfile: String?
public var buildArguments: [String]
public var ports: [String]
public var volumes: [String]
public var environment: [String]
public var dependencies: [String]
public var networks: [String]
public var command: [String]
public var workingDirectory: String?
public var restart: String?
}
public enum ComposeError: Error, CustomStringConvertible {
case message(String)
public var description: String { if case .message(let value) = self { return value }; return "Unknown error" }
}
public struct CommandResult {
public let status: Int32
public let output: String
}
public enum Shell {
public static func executable(_ name: String) -> String? {
if name == "container", Bundle.main.bundleURL.pathExtension == "app",
let chosen = UserDefaults.standard.string(forKey: "containerBinary"), !chosen.isEmpty {
return FileManager.default.isExecutableFile(atPath: chosen) ? chosen : nil
}
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
return paths.map { ($0 as NSString).appendingPathComponent(name) }.first { FileManager.default.isExecutableFile(atPath: $0) }
}
@discardableResult public static func run(_ name: String, _ args: [String], directory: URL? = nil, streaming: Bool = false) throws -> CommandResult {
guard let binary = executable(name) else { throw ComposeError.message("Missing executable: \(name). Install Apple's container runtime first.") }
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = args
if let directory { process.currentDirectoryURL = directory }
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
try process.run()
// Drain while running to avoid blocking on full pipe buffers.
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let output = String(decoding: data, as: UTF8.self)
if streaming { print(output, terminator: "") }
return CommandResult(status: process.terminationStatus, output: output)
}
@discardableResult public static func checked(_ name: String, _ args: [String], directory: URL? = nil) throws -> String {
let result = try run(name, args, directory: directory)
guard result.status == 0 else { throw ComposeError.message("\(name) \(args.joined(separator: " ")) failed (\(result.status)):\n\(result.output)") }
return result.output
}
}
public struct ComposeProject {
public let file: URL
public let name: String
public let services: [ComposeService]
public let networks: [String]
public let volumes: [String]
public var directory: URL { file.deletingLastPathComponent() }
public static func discover(_ supplied: String? = nil) throws -> URL {
let manager = FileManager.default
if let supplied { let url = URL(fileURLWithPath: supplied, relativeTo: URL(fileURLWithPath: manager.currentDirectoryPath)).standardizedFileURL; guard manager.fileExists(atPath: url.path) else { throw ComposeError.message("Compose file not found: \(url.path)") }; return url }
for name in ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"] {
let url = URL(fileURLWithPath: manager.currentDirectoryPath).appendingPathComponent(name)
if manager.fileExists(atPath: url.path) { return url }
}
throw ComposeError.message("No compose.yaml found. Use -f FILE or run container-compose init.")
}
public init(file: URL, overrideName: String? = nil) throws {
self.file = file
let source = try String(contentsOf: file, encoding: .utf8)
let envFile = file.deletingLastPathComponent().appendingPathComponent(".env")
let dotenv = (try? String(contentsOf: envFile, encoding: .utf8)) ?? ""
var vars = ProcessInfo.processInfo.environment
for line in dotenv.split(separator: "\n") {
let line = line.trimmingCharacters(in: .whitespaces)
if line.isEmpty || line.hasPrefix("#") { continue }
let parts = line.split(separator: "=", maxSplits: 1).map(String.init)
if parts.count == 2 && vars[parts[0]] == nil { vars[parts[0]] = parts[1].trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) }
}
let expanded = try Self.interpolate(source, vars: vars)
guard !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw ComposeError.message("Compose file is empty: \(file.path). Save your YAML to this file or select another file with -f.")
}
let loaded: Any?
do { loaded = try Yams.load(yaml: expanded) }
catch { throw ComposeError.message("Invalid YAML in \(file.path): \(error)") }
guard let root = loaded as? [String: Any] else {
throw ComposeError.message("Compose file must have a mapping at its root: \(file.path)")
}
guard let servicesValue = root["services"] else {
throw ComposeError.message("Missing top-level services mapping in \(file.path). Top-level keys: \(root.keys.sorted().joined(separator: ", ")). Check the filename and indentation.")
}
guard let rawServices = servicesValue as? [String: Any], !rawServices.isEmpty else {
throw ComposeError.message("The services value must be a nonempty mapping in \(file.path). Check YAML indentation.")
}
let unknownRoot = Set(root.keys).subtracting(["name", "version", "services", "networks", "volumes"]).filter { !$0.hasPrefix("x-") }
guard unknownRoot.isEmpty else { throw ComposeError.message("Unsupported Compose fields: \(unknownRoot.sorted().joined(separator: ", "))") }
for kind in ["networks", "volumes"] {
if let value = root[kind] {
guard let entries = value as? [String: Any] else { throw ComposeError.message("\(kind) must be a mapping") }
for (name, options) in entries {
let mapping = options as? [String: Any]
let defaultDriver = kind == "networks" ? "bridge" : "local"
let simpleDefault = mapping.map { Set($0.keys).isSubset(of: ["driver"]) && ($0["driver"] == nil || $0["driver"] as? String == defaultDriver) } ?? false
guard options is NSNull || simpleDefault else {
throw ComposeError.message("Unsupported \(kind).\(name) options (external, custom driver, name and IPAM semantics are not implemented)")
}
}
}
}
let requestedName = overrideName ?? (root["name"] as? String) ?? file.deletingLastPathComponent().lastPathComponent
self.name = Self.safeName(requestedName)
guard !self.name.isEmpty else { throw ComposeError.message("Project name must contain an ASCII letter or digit") }
self.networks = (root["networks"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
self.volumes = (root["volumes"] as? [String: Any]).map { Array($0.keys).sorted() } ?? []
let resolvedProjectName = self.name
self.services = try rawServices.keys.sorted().map { key in
guard let raw = rawServices[key] as? [String: Any] else { throw ComposeError.message("Service \(key) must be a mapping") }
let supported: Set<String> = ["image", "build", "ports", "volumes", "environment", "depends_on", "networks", "command", "working_dir", "restart"]
let ignored = Set(raw.keys).subtracting(supported)
if !ignored.isEmpty {
throw ComposeError.message("Service \(key) contains options not yet supported by Apple Container: \(ignored.sorted().joined(separator: ", ")). Refusing to silently ignore them.")
}
for field in ["ports", "volumes"] {
if let value = raw[field] {
guard let list = value as? [Any], list.allSatisfy({ $0 is String || (field == "ports" && $0 is Int) }) else {
throw ComposeError.message("Service \(key) \(field) supports short scalar syntax only; long syntax is not implemented")
}
}
}
if let networks = raw["networks"] as? [String: Any] {
guard networks.values.allSatisfy({ $0 is NSNull || ($0 as? [String: Any])?.isEmpty == true }) else {
throw ComposeError.message("Service \(key) network aliases and attachment options are not implemented")
}
}
if let dependencies = raw["depends_on"] as? [String: Any] {
for (_, value) in dependencies {
guard let options = value as? [String: Any], Set(options.keys).isSubset(of: ["condition"]), options["condition"] as? String == "service_started" else {
throw ComposeError.message("Service \(key) supports depends_on service_started only; health/completion gating is not implemented")
}
}
}
let build = raw["build"]
let buildMap = build as? [String: Any]
if let buildMap {
let unknown = Set(buildMap.keys).subtracting(["context", "dockerfile", "args"])
guard unknown.isEmpty else { throw ComposeError.message("Unsupported build options for \(key): \(unknown.sorted().joined(separator: ", "))") }
}
let context = (build as? String) ?? (buildMap?["context"] as? String)
var buildArguments: [String] = []
if let value = buildMap?["args"] {
if let mapping = value as? [String: Any] {
for key in mapping.keys.sorted() {
let value = mapping[key]!
if value is NSNull {
if let inherited = vars[key] { buildArguments.append("\(key)=\(inherited)") }
} else if value is String || value is NSNumber {
buildArguments.append("\(key)=\(value)")
} else { throw ComposeError.message("Build argument \(key) for \(key) must be a scalar") }
}
} else if let list = value as? [String] {
for item in list {
if item.contains("=") { buildArguments.append(item) }
else if let inherited = vars[item] { buildArguments.append("\(item)=\(inherited)") }
}
} else { throw ComposeError.message("Service \(key) build.args must be a mapping or string list") }
}
let image = (raw["image"] as? String) ?? "\(resolvedProjectName)-\(key):latest"
guard context != nil || raw["image"] is String else { throw ComposeError.message("Service \(key) requires image or build") }
let deps: [String]
if let values = raw["depends_on"] as? [String] { deps = values }
else if let values = raw["depends_on"] as? [String: Any] { deps = Array(values.keys) }
else { deps = [] }
let env: [String]
if let values = raw["environment"] as? [String] { env = values }
else if let values = raw["environment"] as? [String: Any] { env = values.keys.sorted().compactMap { key in values[key].map { "\(key)=\($0)" } } }
else { env = [] }
let networks = (raw["networks"] as? [String]) ?? (raw["networks"] as? [String: Any]).map { Array($0.keys) } ?? []
let command: [String]
if let arr = raw["command"] as? [String] { command = arr }
else if let str = raw["command"] as? String { command = ["sh", "-c", str] }
else { command = [] }
return ComposeService(id: key, image: image, buildContext: context, dockerfile: buildMap?["dockerfile"] as? String, buildArguments: buildArguments, ports: Self.strings(raw["ports"]), volumes: Self.strings(raw["volumes"]), environment: env, dependencies: deps, networks: networks, command: command, workingDirectory: raw["working_dir"] as? String, restart: raw["restart"] as? String)
}
let ids = Set(services.map(\.id))
for service in services { for dep in service.dependencies where !ids.contains(dep) { throw ComposeError.message("Unknown dependency \(dep) for service \(service.id)") } }
_ = try orderedServices()
}
private static func strings(_ value: Any?) -> [String] { (value as? [Any])?.compactMap { $0 as? String ?? ($0 as? Int).map(String.init) } ?? [] }
public static func safeName(_ value: String) -> String {
let mapped = value.lowercased().map { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-" || $0 == "_") ? $0 : "-" }
return String(mapped).trimmingCharacters(in: CharacterSet(charactersIn: "-_"))
}
private static func interpolate(_ text: String, vars: [String: String]) throws -> String {
let regex = try NSRegularExpression(pattern: #"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-|:\?|\?|-)([^}]*))?\}"#)
var output = text
for match in regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).reversed() {
guard let range = Range(match.range, in: output), let keyRange = Range(match.range(at: 1), in: output) else { continue }
let key = String(output[keyRange]); let mode = Range(match.range(at: 2), in: output).map { String(output[$0]) } ?? ""
let fallback = Range(match.range(at: 3), in: output).map { String(output[$0]) } ?? ""
let value = vars[key]
if (mode == "?" || mode == ":?") && (value == nil || (mode == ":?" && value == "")) { throw ComposeError.message("Missing required environment variable \(key): \(fallback)") }
let replacement = ((mode == ":-" && (value == nil || value == "")) || (mode == "-" && value == nil)) ? fallback : (value ?? "")
output.replaceSubrange(range, with: replacement)
}
return output.replacingOccurrences(of: "$$", with: "$")
}
public func orderedServices() throws -> [ComposeService] {
var result: [ComposeService] = []; var visiting = Set<String>(); var visited = Set<String>()
let lookup = Dictionary(uniqueKeysWithValues: services.map { ($0.id, $0) })
func visit(_ key: String) throws {
if visiting.contains(key) { throw ComposeError.message("Dependency cycle involving \(key)") }
if visited.contains(key) { return }
visiting.insert(key)
for dependency in lookup[key]!.dependencies.sorted() { try visit(dependency) }
visiting.remove(key); visited.insert(key); result.append(lookup[key]!)
}
for service in services { try visit(service.id) }
return result
}
public func containerName(_ service: ComposeService) -> String { "\(name)-\(service.id)-1" }
public func networkName(_ name: String) -> String { "\(self.name)-\(name)" }
public func volumeName(_ name: String) -> String { "\(self.name)-\(name)" }
}
public struct ComposeEngine {
public let project: ComposeProject
public init(_ project: ComposeProject) { self.project = project }
private func run(_ args: [String]) throws -> String { try Shell.checked("container", args, directory: project.directory) }
private func path(_ value: String) -> String { URL(fileURLWithPath: value, relativeTo: project.directory).standardizedFileURL.path }
private func existingContainers() throws -> String { try run(["list", "--all"]) }
private func exists(_ name: String) throws -> Bool { let result = try Shell.run("container", ["inspect", name]); return result.status == 0 }
private func ensureNetwork(_ network: String) throws {
let name = project.networkName(network)
let result = try Shell.run("container", ["network", "create", name])
if result.status != 0 && !result.output.localizedCaseInsensitiveContains("already exists") { throw ComposeError.message(result.output) }
}
private func ensureVolume(_ volume: String) throws {
let name = project.volumeName(volume)
let result = try Shell.run("container", ["volume", "create", name])
if result.status != 0 && !result.output.localizedCaseInsensitiveContains("already exists") { throw ComposeError.message(result.output) }
}
private func build(_ service: ComposeService) throws {
guard let context = service.buildContext else { return }
var args = ["build", "-t", service.image]
for argument in service.buildArguments { args += ["--build-arg", argument] }
if let dockerfile = service.dockerfile { args += ["-f", path((context as NSString).appendingPathComponent(dockerfile))] }
args.append(path(context))
print(try run(args))
}
public func execute(_ action: String, args: [String] = [], attached: Bool = false, verbosity: Int = 0) throws -> String {
switch action {
case "doctor":
var lines = ["Container Compose diagnostics", "Project: \(project.file.path)"]
guard let binary = Shell.executable("container") else { return (lines + ["FAIL: Apple container CLI not found. Install from https://github.com/apple/container/releases"]).joined(separator: "\n") }
lines.append("CLI: \(binary)")
let version = try Shell.run("container", ["--version"]); lines.append(version.output.trimmingCharacters(in: .whitespacesAndNewlines))
let status = try Shell.run("container", ["system", "status"]); lines.append("Runtime: \(status.status == 0 ? "available" : "not ready")\n\(status.output)")
return lines.joined(separator: "\n")
case "config": return try String(contentsOf: project.file, encoding: .utf8) + "\nValidated \(project.services.count) services; startup order: \(project.orderedServices().map(\.id).joined(separator: ", "))\n"
case "pull":
var output = ""
for service in project.services where service.buildContext == nil { output += try run(["image", "pull", service.image]) }
return output
case "build":
for service in project.services { try build(service) }
return "Build completed.\n"
case "up":
let interrupt = attached ? ForegroundInterrupt() : nil
var managed: [String] = []
var followers: [Process] = []
defer {
for follower in followers where follower.isRunning { follower.terminate() }
for follower in followers { follower.waitUntilExit() }
if interrupt?.interrupted == true {
fputs("Stopping project gracefully…\n", stderr)
for name in managed.reversed() {
do { _ = try run(["stop", name]) }
catch { fputs("Failed to stop \(name): \(error)\n", stderr) }
}
}
}
let rebuild = args.contains("--build")
if verbosity > 0 {
fputs("container-compose: project=\(project.name) mode=\(attached ? "foreground" : "detached") verbosity=\(verbosity)\n", stderr)
}
if verbosity > 1 { fputs("Compose file: \(project.file.path)\n", stderr) }
if verbosity > 2 { fputs("Startup order: \(try project.orderedServices().map(\.id).joined(separator: ", "))\n", stderr) }
for network in project.networks { try ensureNetwork(network) }
for volume in project.volumes { try ensureVolume(volume) }
var output = ""
for service in try project.orderedServices() {
if interrupt?.interrupted == true { break }
if rebuild { try build(service) }
let name = project.containerName(service)
managed.append(name)
if try exists(name) {
let inspection = try run(["inspect", name])
let containers = try JSONSerialization.jsonObject(with: Data(inspection.utf8)) as? [[String: Any]]
if let status = containers?.first?["status"] as? [String: Any], status["state"] as? String == "stopped" {
output += try run(["start", name])
}
output += "Already exists: \(name) (use down before changing configuration)\n"; continue }
var cmd = ["run", "-d", "--name", name]
for port in service.ports { cmd += ["-p", port] }
for item in service.environment { cmd += ["-e", item] }
if let wd = service.workingDirectory { cmd += ["-w", wd] }
for item in service.volumes {
let parts = item.split(separator: ":", omittingEmptySubsequences: false).map(String.init)
guard parts.count >= 2 else { throw ComposeError.message("Invalid volume mount: \(item)") }
let source = parts[0].hasPrefix(".") || parts[0].hasPrefix("/") || parts[0].hasPrefix("~") ? path((parts[0] as NSString).expandingTildeInPath) : project.volumeName(parts[0])
if source.hasPrefix("/") && !FileManager.default.fileExists(atPath: source) {
// Docker Compose creates missing directory bind sources by default.
try FileManager.default.createDirectory(atPath: source, withIntermediateDirectories: true)
}
cmd += ["-v", ([source] + parts.dropFirst()).joined(separator: ":")]
}
if let first = service.networks.first { cmd += ["--network", project.networkName(first)] }
cmd.append(service.image); cmd += service.command
output += try run(cmd) + "\nStarted \(name)\n"
if service.networks.count > 1 { output += "WARNING: multiple networks requested for \(service.id); only first attached\n" }
if service.restart != nil { output += "WARNING: restart policy requires external supervision; not enabled for \(service.id)\n" }
}
if attached && interrupt?.interrupted != true {
print(output, terminator: "")
fflush(stdout)
guard let binary = Shell.executable("container") else { throw ComposeError.message("Missing container executable") }
for name in managed {
let follower = Process()
follower.executableURL = URL(fileURLWithPath: binary)
follower.arguments = ["logs", "--follow", name]
follower.standardOutput = FileHandle.standardOutput
follower.standardError = FileHandle.standardError
try follower.run()
followers.append(follower)
}
while followers.contains(where: { $0.isRunning }) && interrupt?.interrupted != true {
if let failed = followers.first(where: { !$0.isRunning && $0.terminationStatus != 0 }) {
throw ComposeError.message("Log follower failed (\(failed.terminationStatus))")
}
Thread.sleep(forTimeInterval: 0.05)
}
if interrupt?.interrupted != true {
for follower in followers where follower.terminationStatus != 0 {
throw ComposeError.message("Log follower failed (\(follower.terminationStatus))")
}
}
return ""
}
return output
case "down":
var output = ""
for service in (try project.orderedServices()).reversed() {
let name = project.containerName(service)
if try exists(name) { _ = try? run(["stop", name]); output += try run(["delete", name]) }
}
for network in project.networks { _ = try? run(["network", "delete", project.networkName(network)]) }
if args.contains("--volumes") { for volume in project.volumes { _ = try? run(["volume", "delete", project.volumeName(volume)]) } }
return output + "Project stopped.\n"
case "clean":
let dry = args.contains("--dry-run")
let all = args.contains("--all") || args.contains("--project")
let selected = args.first { !$0.hasPrefix("-") }
let targets = all ? project.services : project.services.filter { $0.id == selected || project.containerName($0) == selected }
guard !targets.isEmpty else { throw ComposeError.message("Select a service/container or use --all; see container-compose clean --help") }
var output = ""
for service in targets {
let name = project.containerName(service)
if try exists(name) {
output += "\(dry ? "Would remove" : "Removing") container \(name)\n"
if !dry { _ = try? run(["stop", name]); output += try run(["delete", name]) }
}
}
output += "Named volumes, shared images, external networks and host bind paths were preserved.\n"
return output
case "prune":
// Only safe, explicit image cleanup; no blind deletion of user data.
guard args.contains("--images") else { return "No resources deleted. Use container-compose prune --images to request unused image pruning; named volumes are never pruned automatically.\n" }
if args.contains("--dry-run") { return "Dry run: inspect unused images with container image list; no changes made.\n" }
let result = try Shell.run("container", ["image", "prune"])
guard result.status == 0 else { throw ComposeError.message("Image prune unsupported or failed: \(result.output)") }
return result.output
case "ps": return try run(["list", "--all"])
case "logs":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services { output += "=== \(service.id) ===\n" + (try run(["logs", project.containerName(service)])) }
return output
case "start", "stop", "restart":
let selected = args.filter { !$0.hasPrefix("-") }
let unknown = Set(selected).subtracting(project.services.map(\.id))
guard unknown.isEmpty else { throw ComposeError.message("Unknown services: \(unknown.sorted().joined(separator: ", "))") }
let services = selected.isEmpty ? project.services : project.services.filter { selected.contains($0.id) }
var output = ""
for service in services {
let name = project.containerName(service)
if action == "restart" { output += try run(["stop", name]); output += try run(["start", name]) }
else { output += try run([action, name]) }
}
return output
case "exec":
guard let service = args.first, let match = project.services.first(where: { $0.id == service }), args.count > 1 else { throw ComposeError.message("Usage: container-compose exec SERVICE COMMAND [ARGS]") }
guard let binary = Shell.executable("container") else { throw ComposeError.message("Missing container executable") }
let process = Process(); process.executableURL = URL(fileURLWithPath: binary)
process.currentDirectoryURL = project.directory
process.arguments = ["exec"] + (isatty(STDIN_FILENO) != 0 ? ["-i", "-t"] : ["-i"]) + [project.containerName(match)] + Array(args.dropFirst())
process.standardInput = FileHandle.standardInput; process.standardOutput = FileHandle.standardOutput; process.standardError = FileHandle.standardError
try process.run(); process.waitUntilExit()
guard process.terminationStatus == 0 else { throw ComposeError.message("exec failed (\(process.terminationStatus))") }
return ""
default: throw ComposeError.message("Unsupported command: \(action)")
}
}
}