new features
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
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 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? {
|
||||
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 accompose 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 let root = try Yams.load(yaml: expanded) as? [String: Any], let rawServices = root["services"] as? [String: Any], !rawServices.isEmpty else { throw ComposeError.message("Compose file must contain a nonempty services mapping") }
|
||||
let requestedName = overrideName ?? (root["name"] as? String) ?? file.deletingLastPathComponent().lastPathComponent
|
||||
self.name = Self.safeName(requestedName)
|
||||
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.")
|
||||
}
|
||||
let build = raw["build"]
|
||||
let buildMap = build as? [String: Any]
|
||||
let context = (build as? String) ?? (buildMap?["context"] as? String)
|
||||
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, 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]
|
||||
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 = ["Apple 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("accompose: 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 accompose 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 accompose 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 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 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: accompose exec SERVICE COMMAND [ARGS]") }
|
||||
return try run(["exec", project.containerName(match)] + Array(args.dropFirst()))
|
||||
default: throw ComposeError.message("Unsupported command: \(action)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user