new features
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
public enum ResourceKind: String, CaseIterable, Identifiable, Sendable {
|
||||
case monitor, containers, images, networks, volumes, projects
|
||||
public var id: String { rawValue }
|
||||
public var title: String { rawValue.capitalized }
|
||||
public var symbol: String { switch self { case .monitor: "waveform.path.ecg"; case .containers: "shippingbox"; case .images: "square.stack.3d.up"; case .networks: "network"; case .volumes: "externaldrive"; case .projects: "folder" } }
|
||||
public var isContainer: Bool { self == .containers || self == .projects || self == .monitor }
|
||||
public var prefix: [String] { isContainer ? [] : [String(rawValue.dropLast())] }
|
||||
public var listArguments: [String] { isContainer ? ["list", "--all", "--format", "json"] : prefix + ["list", "--format", "json"] }
|
||||
}
|
||||
public struct Resource: Identifiable, Sendable, Hashable {
|
||||
public var id: String
|
||||
public var name: String
|
||||
public var state: String
|
||||
public var image: String
|
||||
public var project: String
|
||||
public var service: String
|
||||
public var ports: String
|
||||
public var raw: String
|
||||
public var driver = "—"
|
||||
public var format = "—"
|
||||
public var source = "—"
|
||||
public var created = "—"
|
||||
public var capacity = "—"
|
||||
public var storageKind = "Named volume"
|
||||
public var usedBy = "—"
|
||||
public var readOnlyStorage = false
|
||||
public var cpuFraction: Double?
|
||||
public var memoryBytes: Double?
|
||||
public var rxBytes: Double?
|
||||
public var txBytes: Double?
|
||||
public var cpu = "—"
|
||||
public var memory = "—"
|
||||
public var network = "—"
|
||||
public var disk = "—"
|
||||
}
|
||||
public enum ServiceError: LocalizedError {
|
||||
case message(String)
|
||||
public var errorDescription: String? { if case .message(let s) = self { return s }; return nil }
|
||||
}
|
||||
private final class Capture: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var data = Data()
|
||||
func append(_ bytes: Data) { lock.lock(); defer { lock.unlock() }; let remaining = 4 * 1024 * 1024 - data.count; if remaining > 0 { data.append(bytes.prefix(remaining)) } }
|
||||
var text: String { lock.lock(); defer { lock.unlock() }; return String(decoding: data, as: UTF8.self) }
|
||||
}
|
||||
public struct ContainerService: Sendable {
|
||||
public var binary: String
|
||||
public var demo: Bool
|
||||
public init(binary: String = ContainerService.defaultBinary, demo: Bool = false) { self.binary = binary; self.demo = demo }
|
||||
public static var defaultBinary: String {
|
||||
let paths = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]
|
||||
return paths.map { $0 + "/container" }.first { FileManager.default.isExecutableFile(atPath: $0) } ?? "/opt/homebrew/bin/container"
|
||||
}
|
||||
public func run(_ args: [String], timeout: TimeInterval = 30) async throws -> String {
|
||||
try await Task.detached { try execute(args, timeout: timeout) }.value
|
||||
}
|
||||
public func execute(_ args: [String], timeout: TimeInterval = 30) throws -> String {
|
||||
if demo {
|
||||
let verb = ["image", "network", "volume"].contains(args.first ?? "") ? args.dropFirst().first ?? "" : args.first ?? ""
|
||||
guard ["list", "inspect", "logs", "stats", "exec"].contains(verb) else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") }
|
||||
return try demoOutput(args)
|
||||
}
|
||||
guard FileManager.default.isExecutableFile(atPath: binary) else { throw ServiceError.message("Apple container CLI was not found at \(binary). Install it or choose its executable in Settings.") }
|
||||
let process = Process(); process.executableURL = URL(fileURLWithPath: binary); process.arguments = args
|
||||
let out = Pipe(), err = Pipe(); process.standardOutput = out; process.standardError = err
|
||||
let stdout = Capture(), stderr = Capture(); let group = DispatchGroup()
|
||||
let ended = DispatchSemaphore(value: 0); process.terminationHandler = { _ in ended.signal() }
|
||||
try process.run()
|
||||
for (handle, capture) in [(out.fileHandleForReading, stdout), (err.fileHandleForReading, stderr)] {
|
||||
group.enter(); DispatchQueue.global().async { defer { group.leave() }; while true { let data = handle.availableData; if data.isEmpty { break }; capture.append(data) } }
|
||||
}
|
||||
let timedOut = ended.wait(timeout: .now() + timeout) == .timedOut
|
||||
if timedOut { process.terminate(); if ended.wait(timeout: .now() + 2) == .timedOut { kill(process.processIdentifier, SIGKILL); process.waitUntilExit() } }
|
||||
group.wait()
|
||||
if timedOut { throw ServiceError.message("container \(args.joined(separator: " ")) timed out.") }
|
||||
guard process.terminationStatus == 0 else { throw ServiceError.message("container \(args.joined(separator: " ")) failed:\n\(stderr.text)") }
|
||||
return stdout.text
|
||||
}
|
||||
public static func parse(_ data: String, kind: ResourceKind, projectHint: String = "") throws -> [Resource] {
|
||||
guard let bytes = data.data(using: .utf8), let rows = try JSONSerialization.jsonObject(with: bytes) as? [[String: Any]] else { throw ServiceError.message("Unexpected container JSON: expected a resource array.") }
|
||||
return try rows.map { row in
|
||||
let cfg = row["configuration"] as? [String: Any] ?? row
|
||||
let status = row["status"] as? [String: Any] ?? [:]
|
||||
let image = cfg["image"] as? [String: Any] ?? [:]
|
||||
let id = kind.isContainer ? (cfg["id"] ?? row["id"]) : (cfg["name"] ?? row["reference"] ?? row["name"] ?? cfg["id"] ?? row["id"])
|
||||
guard let id = id as? String, !id.isEmpty else { throw ServiceError.message("Unsupported \(kind.title) JSON: missing identifier.") }
|
||||
let labels = cfg["labels"] as? [String: String] ?? [:]
|
||||
var project = "", service = ""
|
||||
for prefix in ["com.docker.compose.", "io.container-compose.", "com.container-compose.", "container-compose.", "io.accompose.", "com.accompose.", "accompose."] { if let p = labels[prefix + "project"] { project = p; service = labels[prefix + "service"] ?? ""; break } }
|
||||
if project.isEmpty, !projectHint.isEmpty, id.hasPrefix(projectHint + "-"), id.hasSuffix("-1") { project = projectHint; service = String(id.dropFirst(projectHint.count + 1).dropLast(2)) }
|
||||
var resource = Resource(id: id, name: id, state: status["state"] as? String ?? row["status"] as? String ?? "—", image: image["reference"] as? String ?? image["name"] as? String ?? (cfg["descriptor"] as? [String: Any])?["digest"] as? String ?? "—", project: project, service: service, ports: pretty(cfg["publishedPorts"] ?? []), raw: pretty(row))
|
||||
if kind == .volumes {
|
||||
resource.driver = cfg["driver"] as? String ?? "—"
|
||||
resource.format = cfg["format"] as? String ?? "—"
|
||||
resource.source = cfg["source"] as? String ?? "—"
|
||||
resource.created = cfg["creationDate"] as? String ?? cfg["createdAt"] as? String ?? "—"
|
||||
resource.capacity = Self.bytes(cfg["sizeInBytes"])
|
||||
resource.storageKind = labels["com.apple.container.resource.anonymous"] == nil ? "Named volume" : "Anonymous volume"
|
||||
}
|
||||
return resource
|
||||
}.sorted { $0.name < $1.name }
|
||||
}
|
||||
public func list(_ kind: ResourceKind, project: String = "") async throws -> [Resource] { try Self.parse(await run(kind.listArguments), kind: kind, projectHint: project) }
|
||||
public func withStats(_ resources: [Resource]) async throws -> [Resource] {
|
||||
guard resources.contains(where: { $0.state == "running" }) else { return resources }
|
||||
let output = try await run(["stats", "--no-stream", "--format", "json"])
|
||||
guard let rows = try JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]] else { throw ServiceError.message("Unsupported stats JSON") }
|
||||
return resources.map { resource in var r = resource; if let s = rows.first(where: { $0["id"] as? String == r.id }) { if let cpu = s["cpuUtilization"] as? Double { r.cpuFraction = cpu; r.cpu = String(format: "%.2f%%", cpu * 100) }; r.memoryBytes = s["memoryUsageBytes"] as? Double; r.rxBytes = s["networkRxBytes"] as? Double; r.txBytes = s["networkTxBytes"] as? Double; r.disk = Self.bytes(s["blockReadBytes"]) + " / " + Self.bytes(s["blockWriteBytes"]); r.memory = Self.bytes(s["memoryUsageBytes"]); r.network = Self.bytes(s["networkRxBytes"]) + " / " + Self.bytes(s["networkTxBytes"]) }; return r }
|
||||
}
|
||||
public static func bytes(_ value: Any?) -> String { guard let n = value as? NSNumber else { return "—" }; return ByteCountFormatter.string(fromByteCount: n.int64Value, countStyle: .binary) }
|
||||
public static func detailArguments(kind: ResourceKind, id: String, mode: String) -> [String] {
|
||||
if kind.isContainer { switch mode { case "Logs": return ["logs", "-n", "200", id]; case "Stats": return ["stats", "--no-stream", "--format", "json", id]; case "Processes": return ["exec", id, "ps", "aux"]; default: break } }
|
||||
return kind.prefix + ["inspect", id]
|
||||
}
|
||||
public static func actionArguments(kind: ResourceKind, action: String, id: String) -> [String] { kind.prefix + [action] + (action == "prune" ? [] : [id]) }
|
||||
public func waitForState(id: String, expected: String, timeout: TimeInterval = 15) async throws -> Resource {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while true {
|
||||
let output = try await run(["inspect", id], timeout: min(5, timeout))
|
||||
if let resource = try Self.parse(output, kind: .containers).first(where: { $0.id == id }), resource.state == expected { return resource }
|
||||
guard Date() < deadline else { throw ServiceError.message("The command returned, but \(id) did not reach \(expected) within \(Int(timeout)) seconds. Check Inspect for its current state.") }
|
||||
try await Task.sleep(for: .milliseconds(250))
|
||||
}
|
||||
}
|
||||
public func action(kind: ResourceKind, action: String, resource: Resource?) async throws -> String {
|
||||
guard !demo else { throw ServiceError.message("Demo mode is read-only. Connect to the live runtime to control containers.") }
|
||||
guard resource?.readOnlyStorage != true else { throw ServiceError.message("Host mounts are inspect-only and cannot be deleted as volumes.") }
|
||||
if action == "restart" { guard let r = resource else { throw ServiceError.message("Select a container") }; if r.state == "running" { _ = try await run(["stop", r.id]) }; return try await run(["start", r.id]) }
|
||||
guard action == "prune" || resource != nil else { throw ServiceError.message("Select a resource") }
|
||||
return try await run(Self.actionArguments(kind: kind, action: action, id: resource?.id ?? ""))
|
||||
}
|
||||
public static func pretty(_ value: Any) -> String { guard JSONSerialization.isValidJSONObject(value), let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]), let s = String(data: data, encoding: .utf8) else { return String(describing: value) }; return s }
|
||||
public static func formatted(_ string: String) -> String { guard let data = string.data(using: .utf8), let value = try? JSONSerialization.jsonObject(with: data) else { return string }; return pretty(value) }
|
||||
private func demoOutput(_ args: [String]) throws -> String {
|
||||
if args.first == "list" { return Self.demoContainers }
|
||||
if args.prefix(2) == ["image", "list"] { return "[{\"configuration\":{\"name\":\"nginx:alpine\"}}]" }
|
||||
if args.prefix(2) == ["network", "list"] { return "[{\"id\":\"default\",\"configuration\":{\"name\":\"default\"}}]" }
|
||||
if args.prefix(2) == ["volume", "list"] { return "[{\"configuration\":{\"name\":\"demo-data\",\"driver\":\"local\",\"format\":\"ext4\",\"source\":\"/demo/demo-data.img\",\"creationDate\":\"2026-11-08T12:00:00Z\",\"sizeInBytes\":1073741824}}]" }
|
||||
if args.first == "stats" { return Self.demoStats(at: Date()) }
|
||||
if args.first == "logs" { return "12:00:01 Server ready on :80\n12:00:02 GET /health 200\nDemo mode · no runtime commands executed" }
|
||||
if args.first == "inspect" { return Self.demoContainers }
|
||||
return "Demo: \(args.joined(separator: " ")) (simulated; fixture state unchanged)"
|
||||
}
|
||||
public static func demoStats(at date: Date) -> String {
|
||||
let t = date.timeIntervalSince1970, wave = sin(t / 13)
|
||||
let row: [String: Any] = ["id": "demo-web-1", "cpuUtilization": 0.08 + 0.05 * wave, "memoryUsageBytes": (42 + 4 * wave) * 1_048_576, "networkRxBytes": t * 12 * 1024 - 8 * 1024 * 13 * cos(t / 13), "networkTxBytes": t * 7 * 1024 - 4 * 1024 * 13 * cos(t / 13)]
|
||||
return pretty([row])
|
||||
}
|
||||
public static let demoContainers = """
|
||||
[{"configuration":{"id":"demo-web-1","image":{"reference":"nginx:alpine"},"publishedPorts":[{"hostPort":8080,"containerPort":80}],"labels":{"io.container-compose.project":"demo","io.container-compose.service":"web"}},"status":{"state":"running"}},{"configuration":{"id":"demo-db-1","image":{"reference":"postgres:17"},"labels":{"io.container-compose.project":"demo","io.container-compose.service":"db"}},"status":{"state":"stopped"}}]
|
||||
"""
|
||||
}
|
||||
Reference in New Issue
Block a user