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 = ["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(); var visited = Set() 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)") } } }