456 lines
29 KiB
Swift
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)")
|
|
}
|
|
}
|
|
}
|