diff --git a/ColimaStack/Services/BackendAggregationService.swift b/ColimaStack/Services/BackendAggregationService.swift index 205c9ca..c24f7c0 100644 --- a/ColimaStack/Services/BackendAggregationService.swift +++ b/ColimaStack/Services/BackendAggregationService.swift @@ -243,7 +243,7 @@ struct LiveBackendSnapshotService: BackendSnapshotProviding { private let metricsCollector: MetricsCollecting init( - dockerService: DockerResourceProviding = LiveDockerResourceService(), + dockerService: DockerResourceProviding = SocketDockerResourceService(), kubernetesService: KubernetesResourceProviding = LiveKubernetesResourceService(), metricsCollector: MetricsCollecting = BackendMetricsCollector() ) { @@ -286,6 +286,6 @@ struct LiveBackendSnapshotService: BackendSnapshotProviding { guard (status.runtime ?? profile.runtime) == .docker else { return .idle } - return await dockerService.loadSnapshot(context: status.dockerContext.isEmpty ? profile.dockerContext : status.dockerContext) + return await dockerService.loadSnapshot(socketPath: status.socket.nonEmpty ?? profile.socket) } } diff --git a/ColimaStack/Services/ColimaCLI.swift b/ColimaStack/Services/ColimaCLI.swift index a25c575..83df20a 100644 --- a/ColimaStack/Services/ColimaCLI.swift +++ b/ColimaStack/Services/ColimaCLI.swift @@ -438,18 +438,17 @@ struct LiveColimaCLI: ColimaCLI { guard let dockerURL = toolURL(named: "docker", in: tools) else { return DockerStatus(available: false, context: "", version: "", error: "Not installed") } - let context = await commandOutput(executableURL: dockerURL, arguments: ["context", "show"], timeout: 5) + let expectedContext = colimaStatus.profileName == "default" ? "colima" : "colima-\(colimaStatus.profileName)" if colimaStatus.state != .running { let version = await commandOutput(executableURL: dockerURL, arguments: ["version", "--format", "{{.Server.Version}}"], timeout: 8) - return DockerStatus(available: false, context: context.output, version: version.output, error: "Colima \(colimaStatus.profileName) is \(colimaStatus.state.label.lowercased())") + return DockerStatus(available: false, context: expectedContext, version: version.output, error: "Colima \(colimaStatus.profileName) is \(colimaStatus.state.label.lowercased())") } if let runtime = colimaStatus.runtime, runtime != .docker { - return DockerStatus(available: false, context: context.output, version: "", error: "Colima \(colimaStatus.profileName) uses \(runtime.label), not Docker") + return DockerStatus(available: false, context: expectedContext, version: "", error: "Colima \(colimaStatus.profileName) uses \(runtime.label), not Docker") } - let expectedContext = colimaStatus.profileName == "default" ? "colima" : "colima-\(colimaStatus.profileName)" let version = await commandOutput(executableURL: dockerURL, arguments: ["--context", expectedContext, "version", "--format", "{{.Server.Version}}"], timeout: 8) if let error = version.error { - return DockerStatus(available: false, context: context.output, version: "", error: error) + return DockerStatus(available: false, context: expectedContext, version: "", error: error) } return DockerStatus(available: true, context: expectedContext, version: version.output, error: "") } diff --git a/ColimaStack/Services/DockerResourceService.swift b/ColimaStack/Services/DockerResourceService.swift index 8be8416..a001393 100644 --- a/ColimaStack/Services/DockerResourceService.swift +++ b/ColimaStack/Services/DockerResourceService.swift @@ -1,346 +1,6 @@ import Foundation -protocol DockerResourceProviding { - func loadSnapshot(context: String?) async -> ResourceLoadState - func snapshot(context: String?) async throws -> DockerResourceSnapshot -} - -struct LiveDockerResourceService: DockerResourceProviding { - private let commandRunner: CommandRunProviding - - init(commandRunner: CommandRunProviding = LiveCommandRunService()) { - self.commandRunner = commandRunner - } - - func loadSnapshot(context: String? = nil) async -> ResourceLoadState { - do { - return .loaded(try await snapshot(context: context), updatedAt: Date()) - } catch { - return .failed( - BackendIssue( - severity: .error, - source: .docker, - title: "Unable to load Docker resources", - message: error.localizedDescription, - recoverySuggestion: "Check that Colima is running and the Docker CLI is installed." - ), - lastValue: nil - ) - } - } - - func snapshot(context: String? = nil) async throws -> DockerResourceSnapshot { - let collectedAt = Date() - async let activeContextResult = collectStringResult( - context: context, - arguments: ["context", "show"], - purpose: "Read active Docker context" - ) - async let containersResult = collectJSONLinesResult( - context: context, - arguments: ["ps", "--all", "--no-trunc", "--format", "{{json .}}"], - purpose: "List Docker containers", - source: BackendIssueSource.docker, - transform: Self.container - ) - async let imagesResult = collectJSONLinesResult( - context: context, - arguments: ["images", "--digests", "--no-trunc", "--format", "{{json .}}"], - purpose: "List Docker images", - source: BackendIssueSource.docker, - transform: Self.image - ) - async let volumesResult = collectJSONLinesResult( - context: context, - arguments: ["volume", "ls", "--format", "{{json .}}"], - purpose: "List Docker volumes", - source: BackendIssueSource.docker, - transform: Self.volume - ) - async let networksResult = collectJSONLinesResult( - context: context, - arguments: ["network", "ls", "--no-trunc", "--format", "{{json .}}"], - purpose: "List Docker networks", - source: BackendIssueSource.docker, - transform: Self.network - ) - async let statsResult = collectJSONLinesResult( - context: context, - arguments: ["stats", "--no-stream", "--format", "{{json .}}"], - purpose: "Read Docker container stats", - source: BackendIssueSource.metrics, - transform: Self.stats - ) - async let diskUsageResult = collectJSONLinesResult( - context: context, - arguments: ["system", "df", "--format", "{{json .}}"], - purpose: "Read Docker disk usage", - source: BackendIssueSource.metrics, - transform: Self.diskUsage - ) - - let activeContext = await activeContextResult - let containers = await containersResult - let images = await imagesResult - let volumes = await volumesResult - let networks = await networksResult - let stats = await statsResult - let diskUsage = await diskUsageResult - - var issues = activeContext.issues + containers.issues + images.issues + volumes.issues + networks.issues + stats.issues + diskUsage.issues - let runs = activeContext.runs + containers.runs + images.runs + volumes.runs + networks.runs + stats.runs + diskUsage.runs - - if containers.values.contains(where: { $0.state.lowercased() == "dead" }) { - issues.append( - BackendIssue( - severity: .warning, - source: .docker, - title: "Dead Docker containers detected", - message: "One or more containers are in the dead state." - ) - ) - } - - return DockerResourceSnapshot( - context: activeContext.value ?? context ?? "", - collectedAt: collectedAt, - containers: containers.values, - images: images.values, - volumes: volumes.values, - networks: networks.values, - stats: stats.values, - diskUsage: diskUsage.values, - issues: issues, - commandRuns: runs - ) - } - - private func collectStringResult( - context: String?, - arguments: [String], - purpose: String - ) async -> (value: String?, issues: [BackendIssue], runs: [ManagedCommandRun]) { - var issues: [BackendIssue] = [] - guard let run = await runDocker(context: context, arguments: arguments, purpose: purpose, issues: &issues) else { - return (nil, issues, []) - } - guard run.succeeded else { - issues.append(issue(for: run, source: .docker, title: purpose)) - return (nil, issues, [run]) - } - return (run.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines), issues, [run]) - } - - private func collectJSONLinesResult( - context: String?, - arguments: [String], - purpose: String, - source: BackendIssueSource, - transform: ([String: Any]) -> Value? - ) async -> (values: [Value], issues: [BackendIssue], runs: [ManagedCommandRun]) { - var issues: [BackendIssue] = [] - guard let run = await runDocker(context: context, arguments: arguments, purpose: purpose, issues: &issues) else { - return ([], issues, []) - } - guard run.succeeded else { - issues.append(issue(for: run, source: source, title: purpose)) - return ([], issues, [run]) - } - let parsed = JSONCommandParser.parseJSONLines(run.standardOutput) - if !parsed.malformedLineNumbers.isEmpty { - issues.append( - BackendIssue( - severity: .warning, - source: source, - title: purpose, - message: "Dropped \(parsed.malformedLineNumbers.count) malformed JSON line(s): \(parsed.malformedLineNumbers.map(String.init).joined(separator: ", ")).", - command: run.commandString - ) - ) - } - - var values: [Value] = [] - var invalidRecords = 0 - for object in parsed.objects { - if let value = transform(object) { - values.append(value) - } else { - invalidRecords += 1 - } - } - if invalidRecords > 0 { - issues.append( - BackendIssue( - severity: .warning, - source: source, - title: purpose, - message: "Dropped \(invalidRecords) Docker resource record(s) with missing required fields.", - command: run.commandString - ) - ) - } - return (values, issues, [run]) - } - - private func runDocker( - context: String?, - arguments: [String], - purpose: String, - issues: inout [BackendIssue] - ) async -> ManagedCommandRun? { - do { - return try await commandRunner.run( - ManagedCommandRequest( - toolName: "docker", - arguments: dockerArguments(context: context, subcommand: arguments), - timeout: 15, - purpose: purpose - ) - ) - } catch { - issues.append( - BackendIssue( - severity: .error, - source: .docker, - title: purpose, - message: error.localizedDescription, - recoverySuggestion: "Verify the Docker CLI is installed and reachable from PATH." - ) - ) - return nil - } - } - - private func dockerArguments(context: String?, subcommand: [String]) -> [String] { - guard let context, !context.isEmpty else { return subcommand } - return ["--context", context] + subcommand - } - - private func issue(for run: ManagedCommandRun, source: BackendIssueSource, title: String) -> BackendIssue { - BackendIssue( - severity: .warning, - source: source, - title: title, - message: run.combinedOutput.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Command exited with status \(run.terminationStatus).", - command: run.commandString - ) - } - - nonisolated private static func container(_ object: [String: Any]) -> DockerContainerResource? { - let id = object.string("ID", "Id", "ContainerID") - let name = object.string("Names", "Name") - guard !id.isEmpty || !name.isEmpty else { return nil } - return DockerContainerResource( - id: id.isEmpty ? name : id, - name: name, - image: object.string("Image"), - command: object.string("Command"), - createdAt: object.string("CreatedAt"), - runningFor: object.string("RunningFor"), - ports: object.string("Ports"), - state: object.string("State"), - status: object.string("Status"), - size: object.string("Size"), - labels: object.labels("Labels"), - portBindings: parsePortBindings(object.string("Ports")) - ) - } - - nonisolated private static func parsePortBindings(_ ports: String) -> [DockerContainerResource.PortBinding] { - ports - .split(separator: ",") - .compactMap { raw -> DockerContainerResource.PortBinding? in - let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let arrow = value.range(of: "->") else { return nil } - let host = String(value[.. (port: Int, proto: String)? { - let parts = value.split(separator: "/", maxSplits: 1).map(String.init) - guard let port = Int(parts.first ?? "") else { return nil } - return (port, parts.dropFirst().first?.lowercased() ?? "tcp") - } - - nonisolated private static func image(_ object: [String: Any]) -> DockerImageResource? { - let id = object.string("ID", "Id") - guard !id.isEmpty else { return nil } - return DockerImageResource( - id: id, - repository: object.string("Repository"), - tag: object.string("Tag"), - digest: object.string("Digest"), - createdAt: object.string("CreatedAt"), - createdSince: object.string("CreatedSince"), - size: object.string("Size") - ) - } - - nonisolated private static func volume(_ object: [String: Any]) -> DockerVolumeResource? { - let name = object.string("Name") - guard !name.isEmpty else { return nil } - return DockerVolumeResource( - name: name, - driver: object.string("Driver"), - scope: object.string("Scope"), - mountpoint: object.string("Mountpoint", "MountPoint"), - labels: object.labels("Labels") - ) - } - - nonisolated private static func network(_ object: [String: Any]) -> DockerNetworkResource? { - let id = object.string("ID", "Id") - let name = object.string("Name") - guard !id.isEmpty || !name.isEmpty else { return nil } - return DockerNetworkResource( - id: id.isEmpty ? name : id, - name: name, - driver: object.string("Driver"), - scope: object.string("Scope"), - internalOnly: object.bool("Internal"), - ipv6Enabled: object.bool("IPv6", "EnableIPv6") - ) - } - - nonisolated private static func stats(_ object: [String: Any]) -> DockerStatsResource? { - let id = object.string("Container", "ID") - let name = object.string("Name") - guard !id.isEmpty || !name.isEmpty else { return nil } - return DockerStatsResource( - id: id.isEmpty ? name : id, - name: name, - cpuPercent: object.string("CPUPerc"), - memoryUsage: object.string("MemUsage"), - memoryPercent: object.string("MemPerc"), - networkIO: object.string("NetIO"), - blockIO: object.string("BlockIO"), - pids: object.string("PIDs") - ) - } - - nonisolated private static func diskUsage(_ object: [String: Any]) -> DockerDiskUsageResource? { - let type = object.string("Type") - guard !type.isEmpty else { return nil } - return DockerDiskUsageResource( - type: type, - totalCount: object.string("TotalCount"), - activeCount: object.string("Active"), - size: object.string("Size"), - reclaimable: object.string("Reclaimable") - ) - } +nonisolated protocol DockerResourceProviding { + func loadSnapshot(socketPath: String) async -> ResourceLoadState + func snapshot(socketPath: String) async throws -> DockerResourceSnapshot } diff --git a/ColimaStack/Services/DockerUDSClient.swift b/ColimaStack/Services/DockerUDSClient.swift new file mode 100644 index 0000000..8e95239 --- /dev/null +++ b/ColimaStack/Services/DockerUDSClient.swift @@ -0,0 +1,333 @@ +import Foundation +@preconcurrency import Network + +nonisolated struct DockerUDSQueryItem: Hashable, Sendable { + var name: String + var value: String +} + +nonisolated protocol DockerEngineAPIClient: Sendable { + func get(path: String, queryItems: [DockerUDSQueryItem]) async throws -> Data +} + +nonisolated extension DockerEngineAPIClient { + func get(path: String) async throws -> Data { + try await get(path: path, queryItems: []) + } + + func decode( + _ type: Value.Type, + path: String, + queryItems: [DockerUDSQueryItem] = [], + decoder: JSONDecoder = JSONDecoder() + ) async throws -> Value { + let data = try await get(path: path, queryItems: queryItems) + return try decoder.decode(type, from: data) + } +} + +nonisolated protocol DockerUDSTransport: Sendable { + func send(request: Data, socketPath: String, timeout: TimeInterval) async throws -> Data +} + +nonisolated enum DockerUDSClientError: LocalizedError, Sendable, Equatable { + case missingSocketPath(String) + case connectionFailed(String) + case timeout(TimeInterval) + case invalidResponse(String) + case invalidUTF8Response + case httpStatus(Int, String) + case malformedChunkedBody(String) + + var errorDescription: String? { + switch self { + case let .missingSocketPath(path): + return path.isEmpty + ? "Docker socket path is empty." + : "Docker socket does not exist at \(path)." + case let .connectionFailed(message): + return "Docker socket connection failed: \(message)" + case let .timeout(timeout): + return "Docker socket request timed out after \(String(format: "%.1f", timeout))s." + case let .invalidResponse(message): + return "Docker daemon returned an invalid HTTP response: \(message)" + case .invalidUTF8Response: + return "Docker daemon returned response headers that are not valid UTF-8." + case let .httpStatus(status, body): + return body.isEmpty + ? "Docker daemon returned HTTP \(status)." + : "Docker daemon returned HTTP \(status): \(body)" + case let .malformedChunkedBody(message): + return "Docker daemon returned malformed chunked response data: \(message)" + } + } +} + +nonisolated struct DockerUDSClient: DockerEngineAPIClient { + var socketPath: String + var timeout: TimeInterval + + private let transport: any DockerUDSTransport + + init( + socketPath: String, + timeout: TimeInterval = 8, + transport: any DockerUDSTransport = NetworkDockerUDSTransport() + ) { + self.socketPath = socketPath + self.timeout = timeout + self.transport = transport + } + + func get(path: String, queryItems: [DockerUDSQueryItem] = []) async throws -> Data { + let normalizedSocketPath = Self.normalizedSocketPath(socketPath) + let request = try Self.request(path: path, queryItems: queryItems) + let response = try await Self.withTimeout(seconds: timeout) { + try await transport.send(request: request, socketPath: normalizedSocketPath, timeout: timeout) + } + return try Self.responseBody(from: response) + } + + nonisolated static func normalizedSocketPath(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("unix://") else { return trimmed } + return String(trimmed.dropFirst("unix://".count)) + } + + nonisolated static func request(path: String, queryItems: [DockerUDSQueryItem]) throws -> Data { + let requestPath = Self.requestPath(path: path, queryItems: queryItems) + let raw = "GET \(requestPath) HTTP/1.1\r\n" + + "Host: docker\r\n" + + "Accept: application/json\r\n" + + "Connection: close\r\n" + + "\r\n" + guard let data = raw.data(using: .utf8) else { + throw DockerUDSClientError.invalidUTF8Response + } + return data + } + + nonisolated static func responseBody(from response: Data) throws -> Data { + let headerTerminator = Data([13, 10, 13, 10]) + guard let headerRange = response.range(of: headerTerminator) else { + throw DockerUDSClientError.invalidResponse("missing header terminator") + } + let headerData = response[..= 2, let status = Int(statusParts[1]) else { + throw DockerUDSClientError.invalidResponse("invalid HTTP status line: \(statusLine)") + } + + let headers = lines.dropFirst().reduce(into: [String: String]()) { result, line in + guard let separator = line.firstIndex(of: ":") else { return } + let name = line[..= length else { + throw DockerUDSClientError.invalidResponse("body shorter than Content-Length") + } + body = Data(body.prefix(length)) + } + + guard (200..<300).contains(status) else { + let bodyText = String(data: body, encoding: .utf8) ?? "" + throw DockerUDSClientError.httpStatus(status, bodyText) + } + + return body + } + + nonisolated static func decodeChunkedBody(_ body: Data) throws -> Data { + let bytes = [UInt8](body) + var index = 0 + var decoded = Data() + + while index < bytes.count { + guard let lineEnd = Self.crlfIndex(in: bytes, startingAt: index) else { + throw DockerUDSClientError.malformedChunkedBody("missing chunk size terminator") + } + guard let sizeLine = String(bytes: bytes[index.. String { + let normalizedPath = path.hasPrefix("/") ? path : "/\(path)" + guard !queryItems.isEmpty else { return normalizedPath } + + var components = URLComponents() + components.path = normalizedPath + components.queryItems = queryItems.map { URLQueryItem(name: $0.name, value: $0.value) } + return components.string ?? normalizedPath + } + + private nonisolated static func crlfIndex(in bytes: [UInt8], startingAt start: Int) -> Int? { + guard start < bytes.count else { return nil } + for index in start..<(bytes.count - 1) where bytes[index] == 13 && bytes[index + 1] == 10 { + return index + } + return nil + } + + private nonisolated static func withTimeout( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> Value + ) async throws -> Value { + try await withThrowingTaskGroup(of: Value.self) { group in + group.addTask { + try await operation() + } + group.addTask { + let nanoseconds = UInt64(max(seconds, 0) * 1_000_000_000) + try await Task.sleep(nanoseconds: nanoseconds) + throw DockerUDSClientError.timeout(seconds) + } + guard let value = try await group.next() else { + throw DockerUDSClientError.invalidResponse("request did not produce a result") + } + group.cancelAll() + return value + } + } +} + +nonisolated struct NetworkDockerUDSTransport: DockerUDSTransport { + func send(request: Data, socketPath: String, timeout: TimeInterval) async throws -> Data { + _ = timeout + guard !socketPath.isEmpty else { + throw DockerUDSClientError.missingSocketPath(socketPath) + } + guard FileManager.default.fileExists(atPath: socketPath) else { + throw DockerUDSClientError.missingSocketPath(socketPath) + } + + let connection = NWConnection(to: .unix(path: socketPath), using: .tcp) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let queue = DispatchQueue(label: "app.colimastack.docker-uds-client") + let state = DockerUDSConnectionState(connection: connection, continuation: continuation) + + connection.stateUpdateHandler = { connectionState in + switch connectionState { + case .ready: + connection.send(content: request, completion: .contentProcessed { error in + if let error { + state.fail(DockerUDSClientError.connectionFailed(error.localizedDescription)) + } else { + Self.receive(connection: connection, state: state) + } + }) + case let .failed(error): + state.fail(DockerUDSClientError.connectionFailed(error.localizedDescription)) + case .cancelled: + break + default: + break + } + } + connection.start(queue: queue) + } + } onCancel: { + connection.cancel() + } + } + + private nonisolated static func receive(connection: NWConnection, state: DockerUDSConnectionState) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let data, !data.isEmpty { + state.append(data) + } + if let error { + state.fail(DockerUDSClientError.connectionFailed(error.localizedDescription)) + } else if isComplete { + state.succeed() + } else { + Self.receive(connection: connection, state: state) + } + } + } +} + +private nonisolated final class DockerUDSConnectionState: @unchecked Sendable { + private let connection: NWConnection + private let continuation: CheckedContinuation + private let lock = NSLock() + private var completed = false + private var response = Data() + + init(connection: NWConnection, continuation: CheckedContinuation) { + self.connection = connection + self.continuation = continuation + } + + func append(_ data: Data) { + lock.lock() + response.append(data) + lock.unlock() + } + + func succeed() { + complete { response in + continuation.resume(returning: response) + } + } + + func fail(_ error: Error) { + complete { _ in + continuation.resume(throwing: error) + } + } + + private func complete(_ resume: (Data) -> Void) { + let response: Data + lock.lock() + if completed { + lock.unlock() + return + } + completed = true + response = self.response + lock.unlock() + + connection.cancel() + resume(response) + } +} diff --git a/ColimaStack/Services/SocketDockerResourceService.swift b/ColimaStack/Services/SocketDockerResourceService.swift new file mode 100644 index 0000000..ac430c0 --- /dev/null +++ b/ColimaStack/Services/SocketDockerResourceService.swift @@ -0,0 +1,756 @@ +import Foundation + +nonisolated struct SocketDockerResourceService: DockerResourceProviding { + typealias ClientFactory = @Sendable (String) -> any DockerEngineAPIClient + + private let clientFactory: ClientFactory + private let now: @Sendable () -> Date + + init( + clientFactory: @escaping ClientFactory = { DockerUDSClient(socketPath: $0) }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.clientFactory = clientFactory + self.now = now + } + + func loadSnapshot(socketPath: String) async -> ResourceLoadState { + guard !DockerUDSClient.normalizedSocketPath(socketPath).isEmpty else { + return .failed( + BackendIssue( + severity: .error, + source: .docker, + title: "Unable to load Docker resources", + message: "Docker socket path is empty.", + recoverySuggestion: "Check that Colima is running and exposes a Docker socket." + ), + lastValue: nil + ) + } + + do { + return .loaded(try await snapshot(socketPath: socketPath), updatedAt: now()) + } catch { + return .failed( + BackendIssue( + severity: .error, + source: .docker, + title: "Unable to load Docker resources", + message: error.localizedDescription, + recoverySuggestion: "Check that Colima is running and the Docker socket path is valid." + ), + lastValue: nil + ) + } + } + + func snapshot(socketPath: String) async throws -> DockerResourceSnapshot { + guard !DockerUDSClient.normalizedSocketPath(socketPath).isEmpty else { + throw DockerSocketResourceServiceError.emptySocketPath + } + + let collectedAt = now() + let client = clientFactory(socketPath) + + async let containersResult = containers(client: client) + async let imagesResult = images(client: client) + async let volumesResult = volumes(client: client) + async let networksResult = networks(client: client) + async let diskUsageResult = diskUsage(client: client) + + let containers = await containersResult + let images = await imagesResult + let volumes = await volumesResult + let networks = await networksResult + let diskUsage = await diskUsageResult + let stats = await stats(for: containers.value, client: client) + + var issues = containers.issues + images.issues + volumes.issues + networks.issues + diskUsage.issues + stats.issues + if containers.value.contains(where: { $0.state.lowercased() == "dead" }) { + issues.append( + BackendIssue( + severity: .warning, + source: .docker, + title: "Dead Docker containers detected", + message: "One or more containers are in the dead state." + ) + ) + } + + return DockerResourceSnapshot( + context: Self.contextName(from: socketPath), + collectedAt: collectedAt, + containers: containers.value, + images: images.value, + volumes: volumes.value, + networks: networks.value, + stats: stats.value, + diskUsage: diskUsage.value, + issues: issues, + commandRuns: [] + ) + } + + private func containers(client: any DockerEngineAPIClient) async -> SectionResult<[DockerContainerResource]> { + do { + let response = try await client.decode( + [EngineContainer].self, + path: "/containers/json", + queryItems: [DockerUDSQueryItem(name: "all", value: "1")] + ) + return SectionResult(value: response.compactMap(Self.container), issues: []) + } catch { + return SectionResult(value: [], issues: [issue(title: "List Docker containers", source: .docker, error: error)]) + } + } + + private func images(client: any DockerEngineAPIClient) async -> SectionResult<[DockerImageResource]> { + do { + let response = try await client.decode( + [EngineImage].self, + path: "/images/json", + queryItems: [DockerUDSQueryItem(name: "digests", value: "1")] + ) + return SectionResult(value: response.compactMap(Self.image), issues: []) + } catch { + return SectionResult(value: [], issues: [issue(title: "List Docker images", source: .docker, error: error)]) + } + } + + private func volumes(client: any DockerEngineAPIClient) async -> SectionResult<[DockerVolumeResource]> { + do { + let response = try await client.decode(EngineVolumeList.self, path: "/volumes") + return SectionResult(value: (response.volumes ?? []).compactMap(Self.volume), issues: []) + } catch { + return SectionResult(value: [], issues: [issue(title: "List Docker volumes", source: .docker, error: error)]) + } + } + + private func networks(client: any DockerEngineAPIClient) async -> SectionResult<[DockerNetworkResource]> { + do { + let response = try await client.decode([EngineNetwork].self, path: "/networks") + return SectionResult(value: response.compactMap(Self.network), issues: []) + } catch { + return SectionResult(value: [], issues: [issue(title: "List Docker networks", source: .docker, error: error)]) + } + } + + private func diskUsage(client: any DockerEngineAPIClient) async -> SectionResult<[DockerDiskUsageResource]> { + do { + let response = try await client.decode(EngineDiskUsage.self, path: "/system/df") + return SectionResult(value: Self.diskUsage(response), issues: []) + } catch { + return SectionResult(value: [], issues: [issue(title: "Read Docker disk usage", source: .metrics, error: error)]) + } + } + + private func stats( + for containers: [DockerContainerResource], + client: any DockerEngineAPIClient + ) async -> SectionResult<[DockerStatsResource]> { + let running = containers.filter { $0.state.lowercased() == "running" } + guard !running.isEmpty else { + return SectionResult(value: [], issues: []) + } + + return await withTaskGroup(of: StatFetchResult.self, returning: SectionResult<[DockerStatsResource]>.self) { group in + var nextIndex = 0 + let initialCount = min(8, running.count) + for _ in 0.. StatFetchResult { + do { + let response = try await client.decode( + EngineStats.self, + path: "/containers/\(urlPathComponent(container.id))/stats", + queryItems: [DockerUDSQueryItem(name: "stream", value: "false")] + ) + return .success(Self.stats(response, fallback: container)) + } catch { + return .failure( + BackendIssue( + severity: .warning, + source: .metrics, + title: "Read Docker container stats", + message: "Unable to read stats for \(container.name): \(error.localizedDescription)" + ) + ) + } + } + + private nonisolated func issue(title: String, source: BackendIssueSource, error: Error) -> BackendIssue { + BackendIssue( + severity: .warning, + source: source, + title: title, + message: error.localizedDescription + ) + } + + private nonisolated static func container(_ value: EngineContainer) -> DockerContainerResource? { + let id = value.id ?? "" + let name = cleanContainerName(value.names?.first ?? "") + guard !id.isEmpty || !name.isEmpty else { return nil } + let portBindings = (value.ports ?? []).compactMap(Self.portBinding) + return DockerContainerResource( + id: id.isEmpty ? name : id, + name: name.isEmpty ? String(id.prefix(12)) : name, + image: value.image ?? "", + command: value.command ?? "", + createdAt: formatTimestamp(value.created), + runningFor: "", + ports: portsDescription(value.ports ?? []), + state: value.state ?? "", + status: value.status ?? "", + size: formatContainerSize(rw: value.sizeRw, rootFs: value.sizeRootFs), + labels: value.labels ?? [:], + portBindings: portBindings + ) + } + + private nonisolated static func image(_ value: EngineImage) -> DockerImageResource? { + let id = value.id ?? "" + guard !id.isEmpty || value.repoTags?.isEmpty == false else { return nil } + let repoTag = splitRepoTag(value.repoTags?.first ?? "") + return DockerImageResource( + id: id.isEmpty ? "\(repoTag.repository):\(repoTag.tag)" : id, + repository: repoTag.repository, + tag: repoTag.tag, + digest: digest(from: value.repoDigests?.first ?? ""), + createdAt: formatTimestamp(value.created), + createdSince: "", + size: formatBytes(value.size) + ) + } + + private nonisolated static func volume(_ value: EngineVolume) -> DockerVolumeResource? { + guard let name = value.name, !name.isEmpty else { return nil } + return DockerVolumeResource( + name: name, + driver: value.driver ?? "", + scope: value.scope ?? "", + mountpoint: value.mountpoint ?? "", + labels: value.labels ?? [:] + ) + } + + private nonisolated static func network(_ value: EngineNetwork) -> DockerNetworkResource? { + let id = value.id ?? "" + let name = value.name ?? "" + guard !id.isEmpty || !name.isEmpty else { return nil } + return DockerNetworkResource( + id: id.isEmpty ? name : id, + name: name, + driver: value.driver ?? "", + scope: value.scope ?? "", + internalOnly: value.internalOnly ?? false, + ipv6Enabled: value.ipv6Enabled ?? false + ) + } + + private nonisolated static func diskUsage(_ value: EngineDiskUsage) -> [DockerDiskUsageResource] { + [ + diskUsage( + type: "Images", + total: value.images?.count ?? 0, + active: value.images?.filter { ($0.containers ?? 0) > 0 }.count ?? 0, + size: value.images?.reduce(Int64(0)) { $0 + ($1.size ?? 0) } ?? 0, + reclaimable: value.images?.filter { ($0.containers ?? 0) == 0 }.reduce(Int64(0)) { $0 + ($1.size ?? 0) } ?? 0 + ), + diskUsage( + type: "Containers", + total: value.containers?.count ?? 0, + active: value.containers?.filter { $0.state?.lowercased() == "running" }.count ?? 0, + size: value.containers?.reduce(Int64(0)) { $0 + ($1.sizeRw ?? $1.sizeRootFs ?? 0) } ?? 0, + reclaimable: value.containers?.filter { $0.state?.lowercased() != "running" }.reduce(Int64(0)) { $0 + ($1.sizeRw ?? $1.sizeRootFs ?? 0) } ?? 0 + ), + diskUsage( + type: "Volumes", + total: value.volumes?.count ?? 0, + active: value.volumes?.filter { ($0.usageData?.refCount ?? 0) > 0 }.count ?? 0, + size: value.volumes?.reduce(Int64(0)) { $0 + ($1.usageData?.size ?? 0) } ?? 0, + reclaimable: value.volumes?.filter { ($0.usageData?.refCount ?? 0) == 0 }.reduce(Int64(0)) { $0 + ($1.usageData?.size ?? 0) } ?? 0 + ), + diskUsage( + type: "Build Cache", + total: value.buildCache?.count ?? 0, + active: value.buildCache?.filter { $0.inUse == true }.count ?? 0, + size: value.buildCache?.reduce(Int64(0)) { $0 + ($1.size ?? 0) } ?? 0, + reclaimable: value.buildCache?.filter { $0.inUse != true }.reduce(Int64(0)) { $0 + ($1.size ?? 0) } ?? 0 + ) + ] + } + + private nonisolated static func diskUsage( + type: String, + total: Int, + active: Int, + size: Int64, + reclaimable: Int64 + ) -> DockerDiskUsageResource { + DockerDiskUsageResource( + type: type, + totalCount: "\(total)", + activeCount: "\(active)", + size: formatBytes(size), + reclaimable: "\(formatBytes(reclaimable)) (\(formatWholePercent(total: size, value: reclaimable)))" + ) + } + + private nonisolated static func stats(_ value: EngineStats, fallback container: DockerContainerResource) -> DockerStatsResource { + let id = value.id?.nonEmpty ?? container.id + let name = cleanContainerName(value.name ?? "").nonEmpty ?? container.name + let memoryUsage = value.memoryStats?.usage ?? 0 + let memoryLimit = value.memoryStats?.limit ?? 0 + return DockerStatsResource( + id: id, + name: name, + cpuPercent: cpuPercent(value), + memoryUsage: "\(formatBytes(Int64(memoryUsage))) / \(formatBytes(Int64(memoryLimit)))", + memoryPercent: percent(memoryLimit == 0 ? 0 : Double(memoryUsage) / Double(memoryLimit) * 100), + networkIO: networkIO(value.networks), + blockIO: blockIO(value.blkioStats), + pids: "\(value.pidsStats?.current ?? 0)" + ) + } + + private nonisolated static func cpuPercent(_ value: EngineStats) -> String { + let cpuTotal = value.cpuStats?.cpuUsage?.totalUsage ?? 0 + let previousCPUTotal = value.precpuStats?.cpuUsage?.totalUsage ?? 0 + let systemTotal = value.cpuStats?.systemCPUUsage ?? 0 + let previousSystemTotal = value.precpuStats?.systemCPUUsage ?? 0 + guard cpuTotal >= previousCPUTotal, systemTotal > previousSystemTotal else { + return percent(0) + } + + let cpuDelta = Double(cpuTotal - previousCPUTotal) + let systemDelta = Double(systemTotal - previousSystemTotal) + let onlineCPUs = value.cpuStats?.onlineCPUs + ?? UInt64(value.cpuStats?.cpuUsage?.percpuUsage?.count ?? 1) + return percent(cpuDelta / systemDelta * Double(max(onlineCPUs, 1)) * 100) + } + + private nonisolated static func networkIO(_ networks: [String: EngineNetworkStats]?) -> String { + let totals = (networks ?? [:]).values.reduce((rx: UInt64(0), tx: UInt64(0))) { result, value in + (result.rx + (value.rxBytes ?? 0), result.tx + (value.txBytes ?? 0)) + } + return "\(formatBytes(Int64(totals.rx))) / \(formatBytes(Int64(totals.tx)))" + } + + private nonisolated static func blockIO(_ stats: EngineBlkioStats?) -> String { + let totals = (stats?.ioServiceBytesRecursive ?? []).reduce((read: UInt64(0), write: UInt64(0))) { result, value in + switch value.op?.lowercased() { + case "read": + return (result.read + (value.value ?? 0), result.write) + case "write": + return (result.read, result.write + (value.value ?? 0)) + default: + return result + } + } + return "\(formatBytes(Int64(totals.read))) / \(formatBytes(Int64(totals.write)))" + } + + private nonisolated static func portBinding(_ port: EnginePort) -> DockerContainerResource.PortBinding? { + guard let publicPort = port.publicPort, let privatePort = port.privatePort else { return nil } + return DockerContainerResource.PortBinding( + hostIP: port.ip?.nonEmpty ?? "localhost", + hostPort: publicPort, + containerPort: privatePort, + proto: port.type?.lowercased() ?? "tcp" + ) + } + + private nonisolated static func portsDescription(_ ports: [EnginePort]) -> String { + ports.map { port in + let proto = port.type?.lowercased() ?? "tcp" + guard let privatePort = port.privatePort else { return "" } + guard let publicPort = port.publicPort else { return "\(privatePort)/\(proto)" } + let host = port.ip?.nonEmpty ?? "0.0.0.0" + return "\(host):\(publicPort)->\(privatePort)/\(proto)" + } + .filter { !$0.isEmpty } + .joined(separator: ", ") + } + + private nonisolated static func formatContainerSize(rw: Int64?, rootFs: Int64?) -> String { + switch (rw, rootFs) { + case let (.some(rw), .some(rootFs)): + return "\(formatBytes(rw)) (virtual \(formatBytes(rootFs)))" + case let (.some(rw), .none): + return formatBytes(rw) + case let (.none, .some(rootFs)): + return formatBytes(rootFs) + case (.none, .none): + return "" + } + } + + private nonisolated static func splitRepoTag(_ value: String) -> (repository: String, tag: String) { + guard !value.isEmpty else { return ("", "") } + let slash = value.lastIndex(of: "/") + let tagSearchStart = slash.map { value.index(after: $0) } ?? value.startIndex + if let separator = value[tagSearchStart...].lastIndex(of: ":") { + return (String(value[.. String { + guard let separator = value.firstIndex(of: "@") else { return value } + return String(value[value.index(after: separator)...]) + } + + private nonisolated static func cleanContainerName(_ value: String) -> String { + String(value.drop { $0 == "/" }) + } + + private nonisolated static func formatTimestamp(_ timestamp: Int64?) -> String { + guard let timestamp else { return "" } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss 'UTC'" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(timestamp))) + } + + private nonisolated static func formatBytes(_ bytes: Int64?) -> String { + guard let bytes else { return "" } + let units = ["B", "KB", "MB", "GB", "TB"] + var value = Double(bytes) + var unitIndex = 0 + while abs(value) >= 1000, unitIndex < units.count - 1 { + value /= 1000 + unitIndex += 1 + } + if unitIndex == 0 { + return "\(bytes)\(units[unitIndex])" + } + let format = abs(value) >= 10 ? "%.0f%@" : "%.1f%@" + return String(format: format, locale: Locale(identifier: "en_US_POSIX"), value, units[unitIndex]) + } + + private nonisolated static func percent(_ value: Double) -> String { + String(format: "%.2f%%", locale: Locale(identifier: "en_US_POSIX"), value) + } + + private nonisolated static func formatWholePercent(total: Int64, value: Int64) -> String { + guard total > 0 else { return "0%" } + return String(format: "%.0f%%", locale: Locale(identifier: "en_US_POSIX"), Double(value) / Double(total) * 100) + } + + private nonisolated static func urlPathComponent(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/?#") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + nonisolated static func contextName(from socketPath: String) -> String { + let path = DockerUDSClient.normalizedSocketPath(socketPath) + let components = path.split(separator: "/").map(String.init) + let profile: String? + if let colimaIndex = components.lastIndex(where: { $0 == ".colima" || $0 == "colima" }), + components.indices.contains(colimaIndex + 1) { + profile = components[colimaIndex + 1] + } else if components.last == "docker.sock", components.count >= 2 { + profile = components[components.count - 2] + } else { + profile = nil + } + + guard let profile, !profile.isEmpty else { return "" } + return profile == "default" ? "colima" : "colima-\(profile)" + } +} + +private nonisolated enum DockerSocketResourceServiceError: LocalizedError { + case emptySocketPath + + var errorDescription: String? { + switch self { + case .emptySocketPath: + return "Docker socket path is empty." + } + } +} + +private nonisolated struct SectionResult: Sendable { + var value: Value + var issues: [BackendIssue] +} + +private nonisolated enum StatFetchResult: Sendable { + case success(DockerStatsResource) + case failure(BackendIssue) +} + +private nonisolated struct EngineContainer: Decodable, Sendable { + var id: String? + var names: [String]? + var image: String? + var command: String? + var created: Int64? + var ports: [EnginePort]? + var state: String? + var status: String? + var labels: [String: String]? + var sizeRw: Int64? + var sizeRootFs: Int64? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case names = "Names" + case image = "Image" + case command = "Command" + case created = "Created" + case ports = "Ports" + case state = "State" + case status = "Status" + case labels = "Labels" + case sizeRw = "SizeRw" + case sizeRootFs = "SizeRootFs" + } +} + +private nonisolated struct EnginePort: Decodable, Sendable { + var ip: String? + var privatePort: Int? + var publicPort: Int? + var type: String? + + enum CodingKeys: String, CodingKey { + case ip = "IP" + case privatePort = "PrivatePort" + case publicPort = "PublicPort" + case type = "Type" + } +} + +private nonisolated struct EngineImage: Decodable, Sendable { + var id: String? + var repoTags: [String]? + var repoDigests: [String]? + var created: Int64? + var size: Int64? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case repoTags = "RepoTags" + case repoDigests = "RepoDigests" + case created = "Created" + case size = "Size" + } +} + +private nonisolated struct EngineVolumeList: Decodable, Sendable { + var volumes: [EngineVolume]? + + enum CodingKeys: String, CodingKey { + case volumes = "Volumes" + } +} + +private nonisolated struct EngineVolume: Decodable, Sendable { + var name: String? + var driver: String? + var scope: String? + var mountpoint: String? + var labels: [String: String]? + var usageData: EngineVolumeUsage? + + enum CodingKeys: String, CodingKey { + case name = "Name" + case driver = "Driver" + case scope = "Scope" + case mountpoint = "Mountpoint" + case labels = "Labels" + case usageData = "UsageData" + } +} + +private nonisolated struct EngineVolumeUsage: Decodable, Sendable { + var size: Int64? + var refCount: Int? + + enum CodingKeys: String, CodingKey { + case size = "Size" + case refCount = "RefCount" + } +} + +private nonisolated struct EngineNetwork: Decodable, Sendable { + var id: String? + var name: String? + var driver: String? + var scope: String? + var internalOnly: Bool? + var ipv6Enabled: Bool? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case name = "Name" + case driver = "Driver" + case scope = "Scope" + case internalOnly = "Internal" + case ipv6Enabled = "EnableIPv6" + } +} + +private nonisolated struct EngineDiskUsage: Decodable, Sendable { + var images: [EngineDiskImage]? + var containers: [EngineDiskContainer]? + var volumes: [EngineVolume]? + var buildCache: [EngineBuildCache]? + + enum CodingKeys: String, CodingKey { + case images = "Images" + case containers = "Containers" + case volumes = "Volumes" + case buildCache = "BuildCache" + } +} + +private nonisolated struct EngineDiskImage: Decodable, Sendable { + var size: Int64? + var containers: Int? + + enum CodingKeys: String, CodingKey { + case size = "Size" + case containers = "Containers" + } +} + +private nonisolated struct EngineDiskContainer: Decodable, Sendable { + var state: String? + var sizeRw: Int64? + var sizeRootFs: Int64? + + enum CodingKeys: String, CodingKey { + case state = "State" + case sizeRw = "SizeRw" + case sizeRootFs = "SizeRootFs" + } +} + +private nonisolated struct EngineBuildCache: Decodable, Sendable { + var size: Int64? + var inUse: Bool? + + enum CodingKeys: String, CodingKey { + case size = "Size" + case inUse = "InUse" + } +} + +private nonisolated struct EngineStats: Decodable, Sendable { + var id: String? + var name: String? + var cpuStats: EngineCPUStats? + var precpuStats: EngineCPUStats? + var memoryStats: EngineMemoryStats? + var networks: [String: EngineNetworkStats]? + var blkioStats: EngineBlkioStats? + var pidsStats: EnginePidsStats? + + enum CodingKeys: String, CodingKey { + case id + case name + case cpuStats = "cpu_stats" + case precpuStats = "precpu_stats" + case memoryStats = "memory_stats" + case networks + case blkioStats = "blkio_stats" + case pidsStats = "pids_stats" + } +} + +private nonisolated struct EngineCPUStats: Decodable, Sendable { + var cpuUsage: EngineCPUUsage? + var systemCPUUsage: UInt64? + var onlineCPUs: UInt64? + + enum CodingKeys: String, CodingKey { + case cpuUsage = "cpu_usage" + case systemCPUUsage = "system_cpu_usage" + case onlineCPUs = "online_cpus" + } +} + +private nonisolated struct EngineCPUUsage: Decodable, Sendable { + var totalUsage: UInt64? + var percpuUsage: [UInt64]? + + enum CodingKeys: String, CodingKey { + case totalUsage = "total_usage" + case percpuUsage = "percpu_usage" + } +} + +private nonisolated struct EngineMemoryStats: Decodable, Sendable { + var usage: UInt64? + var limit: UInt64? +} + +private nonisolated struct EngineNetworkStats: Decodable, Sendable { + var rxBytes: UInt64? + var txBytes: UInt64? + + enum CodingKeys: String, CodingKey { + case rxBytes = "rx_bytes" + case txBytes = "tx_bytes" + } +} + +private nonisolated struct EngineBlkioStats: Decodable, Sendable { + var ioServiceBytesRecursive: [EngineBlkioEntry]? + + enum CodingKeys: String, CodingKey { + case ioServiceBytesRecursive = "io_service_bytes_recursive" + } +} + +private nonisolated struct EngineBlkioEntry: Decodable, Sendable { + var op: String? + var value: UInt64? + + enum CodingKeys: String, CodingKey { + case op + case value + } +} + +private nonisolated struct EnginePidsStats: Decodable, Sendable { + var current: Int? +} diff --git a/ColimaStackTests/AppStateBackendAggregationTests.swift b/ColimaStackTests/AppStateBackendAggregationTests.swift index 80fbeb1..80361e3 100644 --- a/ColimaStackTests/AppStateBackendAggregationTests.swift +++ b/ColimaStackTests/AppStateBackendAggregationTests.swift @@ -294,7 +294,7 @@ struct AppStateBackendAggregationTests { let snapshot = await service.snapshot(profile: profile, status: status) - #expect(docker.contexts.isEmpty) + #expect(docker.socketPaths.isEmpty) #expect(snapshot.docker == nil) } @@ -402,13 +402,13 @@ private final class RecordingBackendSnapshotProvider: BackendSnapshotProviding { } private final class RecordingDockerResourceProvider: DockerResourceProviding { - private(set) var contexts: [String?] = [] + private(set) var socketPaths: [String] = [] - func loadSnapshot(context: String?) async -> ResourceLoadState { - contexts.append(context) + func loadSnapshot(socketPath: String) async -> ResourceLoadState { + socketPaths.append(socketPath) return .loaded( DockerResourceSnapshot( - context: context ?? "", + context: socketPath, collectedAt: Date(), containers: [], images: [], @@ -423,10 +423,10 @@ private final class RecordingDockerResourceProvider: DockerResourceProviding { ) } - func snapshot(context: String?) async throws -> DockerResourceSnapshot { - contexts.append(context) + func snapshot(socketPath: String) async throws -> DockerResourceSnapshot { + socketPaths.append(socketPath) return DockerResourceSnapshot( - context: context ?? "", + context: socketPath, collectedAt: Date(), containers: [], images: [], @@ -462,7 +462,7 @@ private struct EmptyKubernetesResourceProvider: KubernetesResourceProviding { } private struct FailingDockerResourceProvider: DockerResourceProviding { - func loadSnapshot(context: String?) async -> ResourceLoadState { + func loadSnapshot(socketPath: String) async -> ResourceLoadState { .failed( BackendIssue( severity: .error, @@ -474,7 +474,7 @@ private struct FailingDockerResourceProvider: DockerResourceProviding { ) } - func snapshot(context: String?) async throws -> DockerResourceSnapshot { + func snapshot(socketPath: String) async throws -> DockerResourceSnapshot { throw AppStateAggregationTestError(message: "docker: command not found") } } diff --git a/ColimaStackTests/ColimaStackTests.swift b/ColimaStackTests/ColimaStackTests.swift index 566a8c9..98e884c 100644 --- a/ColimaStackTests/ColimaStackTests.swift +++ b/ColimaStackTests/ColimaStackTests.swift @@ -265,12 +265,6 @@ struct ColimaCLITests { stdout: "28.2.2", stderr: "" ), - "docker context show": ProcessResult( - request: ProcessRequest(arguments: ["docker", "context", "show"]), - exitCode: 0, - stdout: "desktop-linux", - stderr: "" - ), "version --format {{.Server.Version}}": ProcessResult( request: ProcessRequest(arguments: ["docker", "version", "--format", "{{.Server.Version}}"]), exitCode: 0, @@ -351,12 +345,6 @@ struct ColimaCLITests { stdout: "29.2.1", stderr: "" ), - "docker context show": ProcessResult( - request: ProcessRequest(arguments: ["docker", "context", "show"]), - exitCode: 0, - stdout: "desktop-linux", - stderr: "" - ), "--context colima version --format {{.Server.Version}}": ProcessResult( request: ProcessRequest(arguments: ["docker", "--context", "colima", "version", "--format", "{{.Server.Version}}"]), exitCode: 0, @@ -400,12 +388,6 @@ struct ColimaCLITests { stdout: "29.2.1", stderr: "" ), - "docker context show": ProcessResult( - request: ProcessRequest(arguments: ["docker", "context", "show"]), - exitCode: 0, - stdout: "desktop-linux", - stderr: "" - ), "--context colima-dev version --format {{.Server.Version}}": ProcessResult( request: ProcessRequest(arguments: ["docker", "--context", "colima-dev", "version", "--format", "{{.Server.Version}}"]), exitCode: 0, @@ -449,12 +431,6 @@ struct ColimaCLITests { stdout: "29.2.1", stderr: "" ), - "docker context show": ProcessResult( - request: ProcessRequest(arguments: ["docker", "context", "show"]), - exitCode: 0, - stdout: "colima", - stderr: "" - ) ]) let cli = LiveColimaCLI( processRunner: runner, diff --git a/ColimaStackTests/DockerResourceServiceTests.swift b/ColimaStackTests/DockerResourceServiceTests.swift deleted file mode 100644 index 3298f1c..0000000 --- a/ColimaStackTests/DockerResourceServiceTests.swift +++ /dev/null @@ -1,188 +0,0 @@ -import Foundation -import Testing -@testable import ColimaStack - -@MainActor -struct DockerResourceServiceTests { - @Test func snapshotBuildsExplicitContextArgumentsForEveryDockerCommand() async throws { - let runner = FakeCommandRunProvider(outputs: [ - "Read active Docker context": .success("colima-dev\n"), - "List Docker containers": .success(#"{"ID":"abc123","Names":"api","Image":"example/api:latest","State":"running","Labels":"tier=backend,owner=team"}"#), - "List Docker images": .success(#"{"ID":"sha256:image","Repository":"example/api","Tag":"latest","Digest":"sha256:deadbeef","Size":"42MB"}"#), - "List Docker volumes": .success(#"{"Name":"api-data","Driver":"local","Scope":"local","Mountpoint":"/var/lib/docker/volumes/api-data"}"#), - "List Docker networks": .success(#"{"ID":"net123","Name":"bridge","Driver":"bridge","Scope":"local","Internal":"false","IPv6":"true"}"#), - "Read Docker container stats": .success(#"{"Container":"abc123","Name":"api","CPUPerc":"1.25%","MemUsage":"64MiB / 1GiB","MemPerc":"6.25%","NetIO":"1kB / 2kB","BlockIO":"3kB / 4kB","PIDs":"9"}"#), - "Read Docker disk usage": .success(#"{"Type":"Images","TotalCount":"3","Active":"1","Size":"1.2GB","Reclaimable":"500MB (41%)"}"#) - ]) - let service = LiveDockerResourceService(commandRunner: runner) - - let snapshot = try await service.snapshot(context: "colima-dev") - - #expect(snapshot.context == "colima-dev") - #expect(snapshot.containers.first?.id == "abc123") - #expect(snapshot.containers.first?.labels == ["tier": "backend", "owner": "team"]) - #expect(snapshot.images.first?.displayName == "example/api:latest") - #expect(snapshot.volumes.first?.mountpoint == "/var/lib/docker/volumes/api-data") - #expect(snapshot.networks.first?.ipv6Enabled == true) - #expect(snapshot.stats.first?.pids == "9") - #expect(snapshot.diskUsage.first?.type == "Images") - #expect(Set(runner.requests.map(\.arguments)) == Set([ - ["--context", "colima-dev", "context", "show"], - ["--context", "colima-dev", "ps", "--all", "--no-trunc", "--format", "{{json .}}"], - ["--context", "colima-dev", "images", "--digests", "--no-trunc", "--format", "{{json .}}"], - ["--context", "colima-dev", "volume", "ls", "--format", "{{json .}}"], - ["--context", "colima-dev", "network", "ls", "--no-trunc", "--format", "{{json .}}"], - ["--context", "colima-dev", "stats", "--no-stream", "--format", "{{json .}}"], - ["--context", "colima-dev", "system", "df", "--format", "{{json .}}"] - ])) - #expect(runner.requests.allSatisfy { $0.toolName == "docker" && $0.timeout == 15 }) - } - - @Test func snapshotKeepsSuccessfulSectionsWhenOneDockerCommandFails() async throws { - let runner = FakeCommandRunProvider(outputs: [ - "Read active Docker context": .success("colima\n"), - "List Docker containers": .success(""" - {"ID":"live","Names":"web","Image":"nginx","State":"running"} - {"ID":"dead","Names":"worker","Image":"busybox","State":"dead"} - """), - "List Docker images": .success(#"{"ID":"sha256:nginx","Repository":"nginx","Tag":"latest"}"#), - "List Docker volumes": .success(#"{"Name":"cache","Driver":"local"}"#), - "List Docker networks": .failure(status: 1, stdout: "", stderr: "permission denied"), - "Read Docker container stats": .success(#"{"Container":"live","Name":"web","CPUPerc":"0.1%"}"#), - "Read Docker disk usage": .success(#"{"Type":"Build Cache","TotalCount":"2","Active":"0"}"#) - ]) - let service = LiveDockerResourceService(commandRunner: runner) - - let snapshot = try await service.snapshot(context: nil) - - #expect(snapshot.context == "colima") - #expect(snapshot.containers.map(\.name) == ["web", "worker"]) - #expect(snapshot.images.map(\.repository) == ["nginx"]) - #expect(snapshot.volumes.map(\.name) == ["cache"]) - #expect(snapshot.networks.isEmpty) - #expect(snapshot.stats.map(\.name) == ["web"]) - #expect(snapshot.diskUsage.map(\.type) == ["Build Cache"]) - #expect(snapshot.commandRuns.count == 7) - #expect(snapshot.issues.contains { issue in - issue.title == "List Docker networks" - && issue.source == .docker - && issue.severity == .warning - && issue.message == "permission denied" - && issue.command == "/usr/bin/env docker network ls --no-trunc --format {{json .}}" - }) - #expect(snapshot.issues.contains { issue in - issue.title == "Dead Docker containers detected" - && issue.source == .docker - && issue.severity == .warning - }) - } - - @Test func snapshotRecordsRunnerThrowAsDockerIssueAndContinues() async throws { - let runner = FakeCommandRunProvider(errorPurposes: ["Read active Docker context"]) - let service = LiveDockerResourceService(commandRunner: runner) - - let snapshot = try await service.snapshot(context: "colima") - - #expect(snapshot.context == "colima") - #expect(snapshot.commandRuns.count == 6) - #expect(snapshot.issues.contains { issue in - issue.title == "Read active Docker context" - && issue.source == .docker - && issue.severity == .error - && issue.message.contains("boom") - && issue.recoverySuggestion == "Verify the Docker CLI is installed and reachable from PATH." - }) - } - - @Test func malformedJSONLinesProduceAnIssueInsteadOfBeingSilentlyDropped() async throws { - let runner = FakeCommandRunProvider(outputs: [ - "Read active Docker context": .success("colima\n"), - "List Docker containers": .success(""" - {"ID":"valid","Names":"web","Image":"nginx","State":"running"} - {"ID": - {"ID":"also-valid","Names":"api","Image":"busybox","State":"exited"} - """), - "List Docker images": .success("not-json"), - "List Docker volumes": .success(#"{"Driver":"local"}"#), - "List Docker networks": .success(#"{"Name":"bridge","Driver":"bridge"}"#), - "Read Docker container stats": .success(#"{"Name":"web","CPUPerc":"1%"}"#), - "Read Docker disk usage": .success(#"{"TotalCount":"3"}"#) - ]) - let service = LiveDockerResourceService(commandRunner: runner) - - let snapshot = try await service.snapshot(context: nil) - - #expect(snapshot.containers.map(\.id) == ["valid", "also-valid"]) - #expect(snapshot.images.isEmpty) - #expect(snapshot.volumes.isEmpty) - #expect(snapshot.networks.map(\.id) == ["bridge"]) - #expect(snapshot.stats.map(\.id) == ["web"]) - #expect(snapshot.diskUsage.isEmpty) - #expect(snapshot.issues.contains { issue in - issue.title == "List Docker containers" - && issue.source == .docker - && issue.message.contains("malformed JSON") - }) - #expect(snapshot.issues.contains { issue in - issue.title == "List Docker images" - && issue.source == .docker - && issue.message.contains("malformed JSON") - }) - #expect(snapshot.issues.contains { issue in - issue.title == "List Docker volumes" - && issue.source == .docker - && issue.message.contains("missing required fields") - }) - #expect(snapshot.issues.contains { issue in - issue.title == "Read Docker disk usage" - && issue.source == .metrics - && issue.message.contains("missing required fields") - }) - } -} - -private final class FakeCommandRunProvider: CommandRunProviding { - enum Output { - case success(String) - case failure(status: Int32, stdout: String, stderr: String) - } - - private let outputs: [String: Output] - private let errorPurposes: Set - private(set) var requests: [ManagedCommandRequest] = [] - - init(outputs: [String: Output] = [:], errorPurposes: Set = []) { - self.outputs = outputs - self.errorPurposes = errorPurposes - } - - func run(_ request: ManagedCommandRequest) async throws -> ManagedCommandRun { - requests.append(request) - if errorPurposes.contains(request.purpose) { - throw DockerResourceTestError(message: "boom for \(request.purpose)") - } - switch outputs[request.purpose] ?? .success("") { - case let .success(stdout): - return run(for: request, status: 0, stdout: stdout, stderr: "") - case let .failure(status, stdout, stderr): - return run(for: request, status: status, stdout: stdout, stderr: stderr) - } - } - - private func run(for request: ManagedCommandRequest, status: Int32, stdout: String, stderr: String) -> ManagedCommandRun { - ManagedCommandRun( - request: request, - executablePath: "/usr/bin/env", - launchedAt: Date(), - duration: 0, - terminationStatus: status, - standardOutput: stdout, - standardError: stderr - ) - } -} - -private struct DockerResourceTestError: LocalizedError { - var message: String - var errorDescription: String? { message } -} diff --git a/ColimaStackTests/DockerUDSClientTests.swift b/ColimaStackTests/DockerUDSClientTests.swift new file mode 100644 index 0000000..7575fd1 --- /dev/null +++ b/ColimaStackTests/DockerUDSClientTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +@testable import ColimaStack + +struct DockerUDSClientTests { + @Test func getBuildsHTTPRequestAndReturnsContentLengthBody() async throws { + let transport = FakeDockerUDSTransport( + behavior: .response(httpResponse(body: #"{"ok":true}"#)) + ) + let client = DockerUDSClient(socketPath: "unix:///tmp/docker.sock", timeout: 1, transport: transport) + + let body = try await client.get( + path: "/containers/json", + queryItems: [DockerUDSQueryItem(name: "all", value: "1")] + ) + + #expect(String(data: body, encoding: .utf8) == #"{"ok":true}"#) + let requests = await transport.requests() + #expect(requests.map(\.socketPath) == ["/tmp/docker.sock"]) + let requestText = String(data: requests.first?.request ?? Data(), encoding: .utf8) ?? "" + #expect(requestText.hasPrefix("GET /containers/json?all=1 HTTP/1.1\r\n")) + #expect(requestText.contains("Host: docker\r\n")) + #expect(requestText.contains("Connection: close\r\n")) + } + + @Test func getThrowsForNonSuccessStatusWithResponseBody() async throws { + let transport = FakeDockerUDSTransport( + behavior: .response(httpResponse(status: 404, reason: "Not Found", body: "missing")) + ) + let client = DockerUDSClient(socketPath: "/tmp/docker.sock", timeout: 1, transport: transport) + + do { + _ = try await client.get(path: "/missing") + Issue.record("Expected HTTP status error") + } catch let error as DockerUDSClientError { + #expect(error.localizedDescription.contains("HTTP 404")) + #expect(error.localizedDescription.contains("missing")) + } + } + + @Test func getEnforcesTimeout() async throws { + let transport = FakeDockerUDSTransport( + behavior: .delay(seconds: 2, response: httpResponse(body: "{}")) + ) + let client = DockerUDSClient(socketPath: "/tmp/docker.sock", timeout: 0.01, transport: transport) + + do { + _ = try await client.get(path: "/slow") + Issue.record("Expected timeout") + } catch let error as DockerUDSClientError { + #expect(error.localizedDescription.contains("timed out")) + } + } + + @Test func getReassemblesChunkedResponseBody() async throws { + let raw = "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "\r\n" + + "4\r\n" + + "Wiki\r\n" + + "5\r\n" + + "pedia\r\n" + + "0\r\n" + + "\r\n" + let transport = FakeDockerUDSTransport(behavior: .response(Data(raw.utf8))) + let client = DockerUDSClient(socketPath: "/tmp/docker.sock", timeout: 1, transport: transport) + + let body = try await client.get(path: "/chunked") + + #expect(String(data: body, encoding: .utf8) == "Wikipedia") + } +} + +private actor FakeDockerUDSTransport: DockerUDSTransport { + enum Behavior: Sendable { + case response(Data) + case delay(seconds: TimeInterval, response: Data) + case failure(DockerUDSClientError) + } + + private let behavior: Behavior + private var sentRequests: [SentRequest] = [] + + init(behavior: Behavior) { + self.behavior = behavior + } + + func send(request: Data, socketPath: String, timeout: TimeInterval) async throws -> Data { + sentRequests.append(SentRequest(request: request, socketPath: socketPath, timeout: timeout)) + switch behavior { + case let .response(data): + return data + case let .delay(seconds, data): + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return data + case let .failure(error): + throw error + } + } + + func requests() -> [SentRequest] { + sentRequests + } +} + +private struct SentRequest: Sendable { + var request: Data + var socketPath: String + var timeout: TimeInterval +} + +private func httpResponse(status: Int = 200, reason: String = "OK", body: String) -> Data { + let bodyData = Data(body.utf8) + let headers = "HTTP/1.1 \(status) \(reason)\r\n" + + "Content-Length: \(bodyData.count)\r\n" + + "\r\n" + var response = Data(headers.utf8) + response.append(bodyData) + return response +} diff --git a/ColimaStackTests/SocketDockerResourceServiceTests.swift b/ColimaStackTests/SocketDockerResourceServiceTests.swift new file mode 100644 index 0000000..3b9bf77 --- /dev/null +++ b/ColimaStackTests/SocketDockerResourceServiceTests.swift @@ -0,0 +1,241 @@ +import Foundation +import Testing +@testable import ColimaStack + +struct SocketDockerResourceServiceTests { + @Test func snapshotMapsEngineAPIFieldsToResourceModels() async throws { + let client = FakeDockerEngineAPIClient(responses: [ + "/containers/json?all=1": .data(""" + [ + { + "Id":"abc123", + "Names":["/api"], + "Image":"example/api:latest", + "Command":"run-api", + "Created":1700000000, + "Ports":[{"IP":"0.0.0.0","PrivatePort":80,"PublicPort":8080,"Type":"tcp"}], + "State":"running", + "Status":"Up 2 minutes", + "Labels":{"tier":"backend"} + } + ] + """), + "/images/json?digests=1": .data(""" + [ + { + "Id":"sha256:image", + "RepoTags":["example/api:latest"], + "RepoDigests":["example/api@sha256:abc"], + "Created":1700000000, + "Size":42000000 + } + ] + """), + "/volumes": .data(""" + { + "Volumes":[{"Name":"api-data","Driver":"local","Scope":"local","Mountpoint":"/var/lib/docker/volumes/api-data","Labels":{"owner":"team"}}] + } + """), + "/networks": .data(""" + [{"Id":"net123","Name":"bridge","Driver":"bridge","Scope":"local","Internal":false,"EnableIPv6":true}] + """), + "/system/df": .data(""" + { + "Images":[{"Size":1000,"Containers":1},{"Size":2000,"Containers":0}], + "Containers":[{"State":"running","SizeRw":500},{"State":"exited","SizeRw":700}], + "Volumes":[{"Name":"api-data","UsageData":{"Size":4096,"RefCount":1}},{"Name":"cache","UsageData":{"Size":2048,"RefCount":0}}], + "BuildCache":[{"Size":100,"InUse":true},{"Size":50,"InUse":false}] + } + """), + "/containers/abc123/stats?stream=false": .data(statsJSON(id: "abc123", name: "/api", systemDelta: 5_000, cpuDelta: 200)) + ]) + let service = SocketDockerResourceService(clientFactory: { _ in client }) + + let snapshot = try await service.snapshot(socketPath: "unix:///Users/me/.colima/default/docker.sock") + + #expect(snapshot.context == "colima") + #expect(snapshot.containers.first?.name == "api") + #expect(snapshot.containers.first?.labels == ["tier": "backend"]) + #expect(snapshot.containers.first?.portBindings == [ + DockerContainerResource.PortBinding(hostIP: "0.0.0.0", hostPort: 8080, containerPort: 80, proto: "tcp") + ]) + #expect(snapshot.images.first?.repository == "example/api") + #expect(snapshot.images.first?.tag == "latest") + #expect(snapshot.images.first?.digest == "sha256:abc") + #expect(snapshot.images.first?.createdAt == "2023-11-14 22:13:20 UTC") + #expect(snapshot.volumes.first?.mountpoint == "/var/lib/docker/volumes/api-data") + #expect(snapshot.networks.first?.ipv6Enabled == true) + #expect(snapshot.diskUsage.map(\.type) == ["Images", "Containers", "Volumes", "Build Cache"]) + #expect(snapshot.stats.first?.name == "api") + #expect(snapshot.stats.first?.cpuPercent == "8.00%") + #expect(snapshot.stats.first?.memoryPercent == "6.25%") + #expect(snapshot.stats.first?.pids == "9") + } + + @Test func snapshotKeepsSuccessfulSectionsWhenSectionAndStatsRequestsFail() async throws { + let client = FakeDockerEngineAPIClient(responses: emptyResponses().merging([ + "/containers/json?all=1": .data(""" + [ + {"Id":"live","Names":["/web"],"Image":"nginx","State":"running"}, + {"Id":"bad","Names":["/worker"],"Image":"busybox","State":"running"} + ] + """), + "/networks": .failure(.httpStatus(500, "permission denied")), + "/containers/live/stats?stream=false": .data(statsJSON(id: "live", name: "/web", systemDelta: 10_000, cpuDelta: 10)), + "/containers/bad/stats?stream=false": .failure(.httpStatus(500, "stats failed")) + ]) { _, new in new }) + let service = SocketDockerResourceService(clientFactory: { _ in client }) + + let snapshot = try await service.snapshot(socketPath: "unix:///Users/me/.colima/dev/docker.sock") + + #expect(snapshot.context == "colima-dev") + #expect(snapshot.containers.map(\.name) == ["web", "worker"]) + #expect(snapshot.networks.isEmpty) + #expect(snapshot.stats.map(\.name) == ["web"]) + #expect(snapshot.issues.contains { issue in + issue.title == "List Docker networks" + && issue.source == .docker + && issue.severity == .warning + && issue.message.contains("permission denied") + }) + #expect(snapshot.issues.contains { issue in + issue.title == "Read Docker container stats" + && issue.source == .metrics + && issue.severity == .warning + && issue.message.contains("worker") + }) + } + + @Test func zeroSystemDeltaReportsZeroCPUPercent() async throws { + let client = FakeDockerEngineAPIClient(responses: emptyResponses().merging([ + "/containers/json?all=1": .data(#"[{"Id":"idle","Names":["/idle"],"State":"running"}]"#), + "/containers/idle/stats?stream=false": .data(statsJSON(id: "idle", name: "/idle", systemDelta: 0, cpuDelta: 100)) + ]) { _, new in new }) + let service = SocketDockerResourceService(clientFactory: { _ in client }) + + let snapshot = try await service.snapshot(socketPath: "unix:///Users/me/.colima/default/docker.sock") + + #expect(snapshot.stats.first?.cpuPercent == "0.00%") + } + + @Test func contextNameDerivesDefaultAndNamedProfilesFromSocketPath() async throws { + let client = FakeDockerEngineAPIClient(responses: emptyResponses()) + let service = SocketDockerResourceService(clientFactory: { _ in client }) + + let defaultSnapshot = try await service.snapshot(socketPath: "unix:///Users/me/.colima/default/docker.sock") + let devSnapshot = try await service.snapshot(socketPath: "unix:///Users/me/.colima/dev/docker.sock") + + #expect(defaultSnapshot.context == "colima") + #expect(devSnapshot.context == "colima-dev") + } + + @Test func emptySocketPathFailsWithoutCreatingClient() async { + let client = FakeDockerEngineAPIClient(responses: emptyResponses()) + let probe = ClientFactoryProbe(client: client) + let service = SocketDockerResourceService(clientFactory: { path in probe.makeClient(socketPath: path) }) + + let state = await service.loadSnapshot(socketPath: "") + + guard case let .failed(issue, lastValue) = state else { + Issue.record("Expected failed load state") + return + } + #expect(lastValue == nil) + #expect(issue.source == .docker) + #expect(issue.message.contains("empty")) + #expect(probe.callCount() == 0) + #expect(await client.requests().isEmpty) + } +} + +private actor FakeDockerEngineAPIClient: DockerEngineAPIClient { + enum Response: Sendable { + case data(String) + case failure(DockerUDSClientError) + } + + private let responses: [String: Response] + private var requestedKeys: [String] = [] + + init(responses: [String: Response]) { + self.responses = responses + } + + func get(path: String, queryItems: [DockerUDSQueryItem]) async throws -> Data { + let key = Self.key(path: path, queryItems: queryItems) + requestedKeys.append(key) + switch responses[key] { + case let .data(json): + return Data(json.utf8) + case let .failure(error): + throw error + case .none: + throw DockerUDSClientError.httpStatus(404, "No fake response for \(key)") + } + } + + func requests() -> [String] { + requestedKeys + } + + private nonisolated static func key(path: String, queryItems: [DockerUDSQueryItem]) -> String { + guard !queryItems.isEmpty else { return path } + return path + "?" + queryItems.map { "\($0.name)=\($0.value)" }.joined(separator: "&") + } +} + +private final class ClientFactoryProbe: @unchecked Sendable { + private let lock = NSLock() + private let client: any DockerEngineAPIClient + private var calls = 0 + + init(client: any DockerEngineAPIClient) { + self.client = client + } + + func makeClient(socketPath: String) -> any DockerEngineAPIClient { + lock.lock() + calls += 1 + lock.unlock() + return client + } + + func callCount() -> Int { + lock.lock() + defer { lock.unlock() } + return calls + } +} + +private func emptyResponses() -> [String: FakeDockerEngineAPIClient.Response] { + [ + "/containers/json?all=1": .data("[]"), + "/images/json?digests=1": .data("[]"), + "/volumes": .data(#"{"Volumes":[]}"#), + "/networks": .data("[]"), + "/system/df": .data(#"{"Images":[],"Containers":[],"Volumes":[],"BuildCache":[]}"#) + ] +} + +private func statsJSON(id: String, name: String, systemDelta: UInt64, cpuDelta: UInt64) -> String { + """ + { + "id":"\(id)", + "name":"\(name)", + "cpu_stats":{ + "cpu_usage":{"total_usage":\(1_000 + cpuDelta),"percpu_usage":[1,2]}, + "system_cpu_usage":\(5_000 + systemDelta), + "online_cpus":2 + }, + "precpu_stats":{ + "cpu_usage":{"total_usage":1000,"percpu_usage":[1,2]}, + "system_cpu_usage":5000, + "online_cpus":2 + }, + "memory_stats":{"usage":64000000,"limit":1024000000}, + "networks":{"eth0":{"rx_bytes":1000,"tx_bytes":2000}}, + "blkio_stats":{"io_service_bytes_recursive":[{"op":"Read","value":3000},{"op":"Write","value":4000}]}, + "pids_stats":{"current":9} + } + """ +} diff --git a/openspec/changes/docker-socket-migration/.openspec.yaml b/openspec/changes/docker-socket-migration/.openspec.yaml new file mode 100644 index 0000000..ab7f13b --- /dev/null +++ b/openspec/changes/docker-socket-migration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-16 diff --git a/openspec/changes/docker-socket-migration/design.md b/openspec/changes/docker-socket-migration/design.md new file mode 100644 index 0000000..af52683 --- /dev/null +++ b/openspec/changes/docker-socket-migration/design.md @@ -0,0 +1,99 @@ +## Context + +`DockerResourceService` currently spawns 7 child processes per snapshot using the `docker` CLI. The bottleneck is `docker stats --no-stream`, which takes 1–2s because the CLI collects two cgroup samples to compute a delta. All 7 calls run concurrently but each pays process-spawn overhead (~30–100ms on macOS). + +The Docker Engine HTTP API is exposed over a Unix domain socket at the path already parsed from `colima status` into `profile.socket`. Switching to direct socket communication eliminates spawn overhead, enables per-container stat queries to run truly concurrently, and opens the door to event-driven invalidation in the future. + +The existing `DockerResourceProviding` protocol and `DockerResourceSnapshot` model are kept unchanged as the public contract. Only the implementation layer changes. + +## Goals / Non-Goals + +**Goals:** +- Replace `LiveDockerResourceService` with `SocketDockerResourceService` implementing `DockerResourceProviding` +- Add a minimal UDS HTTP/1.1 client using `Network.framework` — no new package dependencies +- Fetch all resource types concurrently via the Engine API +- Compute CPU/memory percentages from raw Engine API stats fields (the CLI did this for us; the API does not) +- Update `BackendAggregationService` to pass `profile.socket` instead of a context string + +**Non-Goals:** +- Remote Docker contexts (TCP/TLS endpoints) — Colima is always local +- Event streaming (`/events`) — out of scope; polling is retained +- Keeping `LiveDockerResourceService` as a fallback — it is removed + +## Decisions + +### 1. UDS HTTP client: `Network.framework` `NWConnection` over a custom URLSession approach + +`URLSession` does not support Unix domain socket endpoints without undocumented private API. `NWConnection` in `Network.framework` has first-class `UnixSocket` endpoint support and is already available on macOS. A thin wrapper sends a raw HTTP/1.1 GET request and reads the response, handling chunked transfer encoding (Docker uses it for some endpoints). + +Alternative considered: import `AsyncHTTPClient` (SwiftNIO-based). Rejected — introduces a heavy dependency for one use case; the API surface we need is two GET patterns. + +### 2. Protocol signature: replace `context: String?` with `socketPath: String` + +The context string was only used to pass `--context` to the CLI. The socket path is a cleaner, more direct parameter. `BackendAggregationService:289` already has `profile.socket`; it will pass it directly. + +The protocol becomes: +```swift +protocol DockerResourceProviding { + func loadSnapshot(socketPath: String) async -> ResourceLoadState + func snapshot(socketPath: String) async throws -> DockerResourceSnapshot +} +``` + +Alternative: keep `context: String?` and resolve the socket path inside the service via a colima CLI call. Rejected — adds latency and a process spawn back into the hot path. + +### 3. Stats: per-container concurrent `GET /containers/{id}/stats?stream=false` + +The Engine API does not have a single "all container stats" endpoint. We fire one request per running container in parallel (Swift structured concurrency `async let` / `withTaskGroup`). Each call returns raw cgroup data; we compute the percentage locally: + +``` +cpu_delta = cpu_usage.total_usage − precpu_usage.total_usage +system_delta = system_cpu_usage − precpu_system_cpu_usage +cpu_pct = (cpu_delta / system_delta) × num_cpus × 100 +mem_pct = memory_stats.usage / memory_stats.limit × 100 +``` + +Alternative: keep `docker stats` CLI for just this call. Rejected — this was the primary source of lag; the point of the migration is to fix it. + +### 4. Model mapping strategy: thin adapter functions, same output types + +All existing `DockerResourceSnapshot` child types (`DockerContainerResource`, `DockerImageResource`, etc.) remain unchanged. The new service adds adapter functions that translate Engine API JSON shapes to these types. Key differences to bridge: + +| Resource | CLI shape | Engine API shape | +|---|---|---| +| Container names | `"api"` string | `["/api"]` array, strip leading `/` | +| Container ports | `"0.0.0.0:8080->80/tcp"` string | Array of `{IP, PrivatePort, PublicPort, Type}` objects | +| Image repo/tag | Separate `Repository`, `Tag` fields | `RepoTags: ["nginx:latest"]` array | +| Image digest | `Digest` field | `RepoDigests: ["nginx@sha256:..."]` array | +| Image created | `CreatedAt` human string | `Created` Unix timestamp (Int) | +| Disk usage | Flat JSON lines | Nested `{Images:[],Containers:[],Volumes:[],BuildCache:[]}` | +| Stats | Pre-computed `"1.25%"` strings | Raw cgroup counters; compute locally | + +### 5. Active context: removed + +`docker context show` was the only call that didn't fetch resources. The context name was stored in `DockerResourceSnapshot.context`. With the socket approach, the Colima profile name is the meaningful identifier. `snapshot.context` will be populated from the socket path (e.g., extract profile name from `~/.colima//docker.sock`). + +## Risks / Trade-offs + +- **Stats concurrency burst** → If there are many containers, N concurrent stat requests hit the daemon simultaneously. Mitigation: cap concurrency with a `TaskGroup` limited to 8 concurrent stats requests. +- **Chunked transfer encoding** → Docker uses chunked encoding on `/containers/{id}/stats`. The UDS client must handle this correctly or response parsing will fail silently. Mitigation: implement a proper chunked decoder in the HTTP client; add a test with a chunked fixture. +- **Socket path unavailable** → If Colima isn't running, `profile.socket` is empty. `SocketDockerResourceService.loadSnapshot` returns `.failed(...)` (same behavior as today when the CLI can't connect). Mitigation: guard on empty socket path before attempting connection. +- **Network.framework availability** → Available on macOS 10.14+; this app targets macOS 13+. No issue. + +## Migration Plan + +1. Add `DockerUDSClient` — the UDS HTTP client — with no changes to existing code +2. Add `SocketDockerResourceService` implementing `DockerResourceProviding` +3. Update `DockerResourceProviding` protocol signature (`socketPath:`) +4. Update `LiveBackendSnapshotService` call site in `BackendAggregationService` +5. Delete `LiveDockerResourceService` +6. Replace `DockerResourceServiceTests` with socket-based fixtures + +No data migration or deployment steps needed — this is entirely in-process. + +**Rollback:** revert the PR; `LiveDockerResourceService` is in git history. + +## Open Questions + +- Should `DockerResourceSnapshot.context` be renamed to `profile` to reflect that it no longer holds a Docker context name? Deferring — a rename is a separate cleanup. +- Should the UDS client be shared with a future Kubernetes socket client, or stay Docker-specific? Deferring — keep it Docker-specific for now. diff --git a/openspec/changes/docker-socket-migration/proposal.md b/openspec/changes/docker-socket-migration/proposal.md new file mode 100644 index 0000000..44c07aa --- /dev/null +++ b/openspec/changes/docker-socket-migration/proposal.md @@ -0,0 +1,32 @@ +## Why + +`DockerResourceService` shells out to the `docker` CLI for every snapshot — 7 child processes per refresh, including `docker stats --no-stream` which takes 1–2s server-side because the CLI polls cgroups twice. This makes the UI feel laggy and unresponsive. Switching to the Docker Engine HTTP API over the Unix domain socket eliminates process spawn overhead and enables concurrent, event-driven data fetching. + +## What Changes + +- Replace `LiveDockerResourceService` with a new `SocketDockerResourceService` that communicates with the Docker Engine API via HTTP/1.1 over the Colima Unix domain socket. +- Add a lightweight UDS HTTP client using `Network.framework` (`NWConnection`) — no new package dependencies. +- Map Docker Engine API JSON responses to the existing `DockerResourceSnapshot` model (containers, images, volumes, networks, stats, disk usage). +- Pass the socket path (already parsed from `colima status` into `profile.socket`) to the service instead of a context name string. +- Remove the `docker context show` CLI call — the active context is already known from Colima state. +- Keep `LiveCommandRunService` and `ProcessRunner` in place for Colima CLI and kubectl calls. + +## Capabilities + +### New Capabilities + +- `docker-engine-api-client`: Thin HTTP/1.1 client that sends requests to and parses responses from the Docker Engine API over a Unix domain socket. Supports GET requests and JSON response decoding. +- `docker-socket-resource-service`: Implementation of `DockerResourceProviding` that uses the Docker Engine API client to fetch containers, images, volumes, networks, stats, and disk usage concurrently. + +### Modified Capabilities + + + +## Impact + +- **Replaces**: `LiveDockerResourceService` (CLI-based) → `SocketDockerResourceService` (socket-based) +- **Call site**: `BackendAggregationService.swift:289` — signature changes from `context: String?` to `socketPath: String` +- **Models**: `DockerResourceSnapshot` and all child models are unchanged; only the field-mapping logic in the service changes +- **Tests**: `DockerResourceServiceTests.swift` — CLI-format fixtures replaced with Engine API JSON fixtures; mock HTTP client replaces mock command runner +- **Dependencies**: No new Swift packages; uses `Network.framework` (already available on macOS) +- **Limitations**: Local Colima sockets only; remote Docker contexts (TCP/TLS) are not supported by this implementation diff --git a/openspec/changes/docker-socket-migration/specs/docker-engine-api-client/spec.md b/openspec/changes/docker-socket-migration/specs/docker-engine-api-client/spec.md new file mode 100644 index 0000000..ea10b91 --- /dev/null +++ b/openspec/changes/docker-socket-migration/specs/docker-engine-api-client/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: Send HTTP GET requests over a Unix domain socket +The client SHALL send HTTP/1.1 GET requests to a Docker Engine API endpoint by connecting to a Unix domain socket path using `Network.framework` `NWConnection`. + +#### Scenario: Successful GET request +- **WHEN** a valid socket path and request path are provided +- **THEN** the client establishes a connection, sends a well-formed HTTP/1.1 GET request, and returns the response body as `Data` + +#### Scenario: Socket path does not exist +- **WHEN** the socket path points to a file that does not exist +- **THEN** the client throws a descriptive error without hanging + +#### Scenario: Connection refused or daemon not running +- **WHEN** the socket exists but the Docker daemon is not listening +- **THEN** the client throws an error within the specified timeout + +### Requirement: Respect a per-request timeout +The client SHALL enforce a configurable timeout on each request and throw a timeout error if the response is not received within that duration. + +#### Scenario: Request exceeds timeout +- **WHEN** the daemon accepts the connection but does not respond within the timeout period +- **THEN** the client cancels the connection and throws a timeout error + +### Requirement: Parse standard HTTP responses +The client SHALL parse the HTTP status line and body from the response, distinguishing 2xx success from error status codes. + +#### Scenario: 200 OK response +- **WHEN** the server returns HTTP 200 with a JSON body +- **THEN** the client returns the body data to the caller + +#### Scenario: Non-2xx response +- **WHEN** the server returns HTTP 4xx or 5xx +- **THEN** the client throws an error containing the status code and response body + +### Requirement: Handle chunked transfer encoding +The client SHALL correctly reassemble chunked HTTP/1.1 response bodies into complete `Data` before returning. + +#### Scenario: Chunked response with multiple chunks +- **WHEN** the server sends a response with `Transfer-Encoding: chunked` containing N chunks +- **THEN** the client returns the fully assembled body as a single contiguous `Data` value + +#### Scenario: Chunked response terminator +- **WHEN** the server sends the terminal zero-size chunk (`0\r\n\r\n`) +- **THEN** the client treats the response as complete and returns + +### Requirement: Support query string parameters +The client SHALL allow callers to pass query parameters that are appended to the request URL path. + +#### Scenario: Query parameters are included in the request line +- **WHEN** the caller provides query parameters (e.g., `all=1`) +- **THEN** the request line contains `?key=value` appended to the path + +### Requirement: Swift concurrency compatibility +The client SHALL be `Sendable` and callable from `async` contexts without blocking the calling actor. + +#### Scenario: Concurrent requests +- **WHEN** multiple callers invoke the client concurrently from async tasks +- **THEN** each request is handled independently without data races diff --git a/openspec/changes/docker-socket-migration/specs/docker-socket-resource-service/spec.md b/openspec/changes/docker-socket-migration/specs/docker-socket-resource-service/spec.md new file mode 100644 index 0000000..6a04ddd --- /dev/null +++ b/openspec/changes/docker-socket-migration/specs/docker-socket-resource-service/spec.md @@ -0,0 +1,104 @@ +## ADDED Requirements + +### Requirement: Implement DockerResourceProviding via Unix socket +`SocketDockerResourceService` SHALL implement the `DockerResourceProviding` protocol, accepting a `socketPath: String` parameter instead of a Docker context name. + +#### Scenario: Successful snapshot with running containers +- **WHEN** `snapshot(socketPath:)` is called with a valid Colima socket path +- **THEN** it returns a fully populated `DockerResourceSnapshot` with containers, images, volumes, networks, stats, and disk usage + +#### Scenario: Empty socket path +- **WHEN** `loadSnapshot(socketPath:)` is called with an empty string +- **THEN** it returns `.failed(...)` with a descriptive issue and does not attempt a connection + +### Requirement: Fetch all resource types concurrently +The service SHALL issue all Engine API requests concurrently using Swift structured concurrency, not sequentially. + +#### Scenario: Concurrent fetch +- **WHEN** `snapshot(socketPath:)` is called +- **THEN** requests for containers, images, volumes, networks, and disk usage are all in-flight simultaneously + +### Requirement: Fetch container stats concurrently per container +The service SHALL fetch stats for each running container individually via `GET /containers/{id}/stats?stream=false`, with all per-container requests running concurrently, capped at a maximum of 8 concurrent stat requests. + +#### Scenario: Multiple running containers +- **WHEN** there are N running containers +- **THEN** up to 8 stat requests are in-flight at a time; all N results are collected before the snapshot is returned + +#### Scenario: No running containers +- **WHEN** no containers are in the running state +- **THEN** no stat requests are made and `snapshot.stats` is empty + +### Requirement: Map Engine API container fields to DockerContainerResource +The service SHALL translate Docker Engine API container JSON to `DockerContainerResource`, handling API-specific field shapes. + +#### Scenario: Container names strip leading slash +- **WHEN** the Engine API returns `Names: ["/api"]` +- **THEN** `DockerContainerResource.name` is `"api"` (no leading `/`) + +#### Scenario: Container ports parsed from object array +- **WHEN** the Engine API returns `Ports: [{IP, PrivatePort, PublicPort, Type}]` +- **THEN** `DockerContainerResource.portBindings` contains the correct `PortBinding` values + +#### Scenario: Container labels parsed from object +- **WHEN** the Engine API returns `Labels: {"tier": "backend"}` +- **THEN** `DockerContainerResource.labels` equals `["tier": "backend"]` + +### Requirement: Map Engine API image fields to DockerImageResource +The service SHALL translate Docker Engine API image JSON to `DockerImageResource`. + +#### Scenario: Repository and tag split from RepoTags +- **WHEN** the Engine API returns `RepoTags: ["nginx:latest"]` +- **THEN** `DockerImageResource.repository` is `"nginx"` and `.tag` is `"latest"` + +#### Scenario: Digest extracted from RepoDigests +- **WHEN** the Engine API returns `RepoDigests: ["nginx@sha256:abc"]` +- **THEN** `DockerImageResource.digest` is `"sha256:abc"` + +#### Scenario: Created timestamp formatted as string +- **WHEN** the Engine API returns `Created: 1700000000` (Unix timestamp) +- **THEN** `DockerImageResource.createdAt` is a human-readable date string + +### Requirement: Compute CPU and memory percentages from raw stats +The service SHALL compute CPU usage percentage and memory usage percentage from the raw cgroup counter fields returned by `GET /containers/{id}/stats?stream=false`. + +#### Scenario: CPU percentage calculation +- **WHEN** stats contain `cpu_stats.cpu_usage.total_usage`, `precpu_stats.cpu_usage.total_usage`, `cpu_stats.system_cpu_usage`, `precpu_stats.system_cpu_usage`, and `cpu_stats.online_cpus` +- **THEN** CPU percent equals `(cpu_delta / system_delta) × online_cpus × 100`, formatted to two decimal places with a `%` suffix + +#### Scenario: Memory percentage calculation +- **WHEN** stats contain `memory_stats.usage` and `memory_stats.limit` +- **THEN** memory percent equals `(usage / limit) × 100`, formatted to two decimal places with a `%` suffix + +#### Scenario: Zero system delta (no change between samples) +- **WHEN** `cpu_stats.system_cpu_usage` equals `precpu_stats.system_cpu_usage` +- **THEN** CPU percent is reported as `"0.00%"` without dividing by zero + +### Requirement: Map Engine API disk usage to DockerDiskUsageResource +The service SHALL translate the nested `GET /system/df` response to the flat `[DockerDiskUsageResource]` format. + +#### Scenario: Disk usage sections present +- **WHEN** the Engine API returns `{Images:[...], Containers:[...], Volumes:[...], BuildCache:[...]}` +- **THEN** each section produces one `DockerDiskUsageResource` entry with `type`, `totalCount`, `size`, and `reclaimable` + +### Requirement: Populate snapshot issues for partial failures +The service SHALL include per-section `BackendIssue` entries in the snapshot when individual API calls fail, allowing successfully-fetched sections to still be returned. + +#### Scenario: Single section fails +- **WHEN** the networks API call returns an error but all other calls succeed +- **THEN** `snapshot.networks` is empty, `snapshot.issues` contains one error for networks, and all other sections are populated + +#### Scenario: Stats for one container fails +- **WHEN** the stats request for one container returns an error +- **THEN** that container is omitted from `snapshot.stats` and a warning issue is added; other containers' stats are returned + +### Requirement: Derive active context name from socket path +The service SHALL populate `DockerResourceSnapshot.context` by extracting the Colima profile name from the socket path (e.g., `~/.colima/default/docker.sock` → `"colima"`, `~/.colima/dev/docker.sock` → `"colima-dev"`). + +#### Scenario: Default profile socket path +- **WHEN** the socket path contains `/colima/default/` +- **THEN** `snapshot.context` is `"colima"` + +#### Scenario: Named profile socket path +- **WHEN** the socket path contains `/colima//` +- **THEN** `snapshot.context` is `"colima-"` diff --git a/openspec/changes/docker-socket-migration/tasks.md b/openspec/changes/docker-socket-migration/tasks.md new file mode 100644 index 0000000..bcd05ec --- /dev/null +++ b/openspec/changes/docker-socket-migration/tasks.md @@ -0,0 +1,47 @@ +## 1. UDS HTTP Client + +- [x] 1.1 Create `DockerUDSClient.swift` — `nonisolated struct` with `socketPath: String` and `timeout: TimeInterval` +- [x] 1.2 Implement `NWConnection` setup for `UnixSocket` endpoint using `Network.framework` +- [x] 1.3 Implement HTTP/1.1 GET request builder (request line, Host header, Connection: close) +- [x] 1.4 Implement response reader: parse status line, headers, and body (Content-Length and chunked transfer encoding) +- [x] 1.5 Implement chunked body reassembly (parse chunk size hex, accumulate chunks, detect terminal `0\r\n\r\n`) +- [x] 1.6 Throw descriptive errors for: connection failure, timeout, non-2xx status, invalid UTF-8 response + +## 2. Docker Socket Resource Service + +- [x] 2.1 Create `SocketDockerResourceService.swift` implementing `DockerResourceProviding` with `socketPath: String` parameter +- [x] 2.2 Implement `snapshot(socketPath:)` — fires concurrent `async let` requests for containers, images, volumes, networks, disk usage +- [x] 2.3 Add `GET /containers/json?all=1` → map to `[DockerContainerResource]` +- [x] 2.4 Add container field adapters: strip leading `/` from names, parse `Ports` object array into `portBindings`, parse `Labels` object +- [x] 2.5 Add `GET /images/json?digests=1` → split `RepoTags[0]` into repository/tag, extract digest from `RepoDigests[0]`, format `Created` Unix timestamp +- [x] 2.6 Add `GET /volumes` → map `Volumes` array to `[DockerVolumeResource]` +- [x] 2.7 Add `GET /networks` → map to `[DockerNetworkResource]` +- [x] 2.8 Add `GET /system/df` → map nested sections (Images, Containers, Volumes, BuildCache) to `[DockerDiskUsageResource]` +- [x] 2.9 Implement per-container stats fetch: `GET /containers/{id}/stats?stream=false` for each running container, capped at 8 concurrent requests via `withTaskGroup` +- [x] 2.10 Implement CPU % calculation: `(cpu_delta / system_delta) × online_cpus × 100`; guard against zero system delta +- [x] 2.11 Implement memory % calculation: `(usage / limit) × 100`; format as `"X.XX%"` string +- [x] 2.12 Implement context name derivation from socket path (`default` → `"colima"`, `` → `"colima-"`) +- [x] 2.13 Return partial snapshot with `BackendIssue` entries when individual section requests fail + +## 3. Protocol and Call Site Updates + +- [x] 3.1 Update `DockerResourceProviding` protocol: replace `context: String?` with `socketPath: String` in both `loadSnapshot` and `snapshot` +- [x] 3.2 Update `LiveBackendSnapshotService.loadDockerSnapshot` in `BackendAggregationService.swift` to pass `profile.socket` instead of a context string +- [x] 3.3 Delete `LiveDockerResourceService` from `DockerResourceService.swift` + +## 4. Tests + +- [x] 4.1 Add `DockerUDSClientTests.swift` — mock `NWConnection` or test against a local echo server; cover: success, non-2xx, timeout, chunked encoding +- [x] 4.2 Replace `DockerResourceServiceTests.swift` with `SocketDockerResourceServiceTests.swift` using a mock `DockerUDSClient` protocol +- [x] 4.3 Add test: containers — names strip slash, ports parse from object array, labels from object +- [x] 4.4 Add test: images — RepoTags split, digest extracted, Created timestamp formatted +- [x] 4.5 Add test: stats — CPU % and memory % computed correctly from raw counters; zero system delta returns `"0.00%"` +- [x] 4.6 Add test: partial failure — one section errors, other sections still populated, issues array has one entry +- [x] 4.7 Add test: context name derivation from socket path (default and named profile) +- [x] 4.8 Add test: empty socket path returns `.failed(...)` without attempting connection + +## 5. Cleanup + +- [x] 5.1 Remove `docker context show` CLI call from any remaining code paths +- [x] 5.2 Verify `ToolLocator` / `CommandRunService` are no longer imported by `DockerResourceService.swift` +- [x] 5.3 Build and run all tests; confirm no regressions in `AppStateBackendAggregationTests` or `BackendSearchIndexTests` diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours