diff --git a/Sources/Services/ContainerAPIService/Client/DockerEngineClient.swift b/Sources/Services/ContainerAPIService/Client/DockerEngineClient.swift new file mode 100644 index 000000000..8022838fd --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/DockerEngineClient.swift @@ -0,0 +1,204 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import NIOCore +import NIOPosix +import Logging + +/// A client for connecting to and communicating with a Docker Engine daemon via Unix socket. +/// +/// This is a minimal implementation suitable for basic connectivity checking and version retrieval. +/// +/// ## Limitations +/// +/// - HTTP response parsing is simplified and may not handle all edge cases +/// - Does not support chunked transfer encoding +/// - Uses polling-based response reading which may add latency +/// - Not suitable for high-frequency or production use without enhancements +/// +/// ## Usage +/// +/// ```swift +/// let client = DockerEngineClient() +/// let connected = try await client.connect() +/// let version = try await client.getVersion() +/// ``` +/// +/// For production use, consider using a full-featured HTTP client library or enhancing +/// this implementation with proper NIO-based async response handling. +public struct DockerEngineClient { + /// The path to the Docker daemon socket + public let socketPath: String + + /// Logger for client operations + private let logger: Logger + + /// Default Docker socket path + public static let defaultSocketPath = "/var/run/docker.sock" + + /// Maximum attempts to read response data before timing out + private static let responseTimeoutAttempts = 10 + + /// Delay between response read attempts in milliseconds + private static let responseReadDelayMs: UInt64 = 50 + + /// Initialize a Docker Engine client + /// - Parameters: + /// - socketPath: Path to the Docker daemon socket (defaults to /var/run/docker.sock) + /// - logger: Optional logger for debugging + public init(socketPath: String = defaultSocketPath, logger: Logger? = nil) { + self.socketPath = socketPath + self.logger = logger ?? Logger(label: "com.apple.container.docker-engine-client") + } + + /// Connect to the Docker Engine and verify connectivity + /// - Returns: True if connection successful, false otherwise + /// - Throws: Error if connection fails + public func connect() async throws -> Bool { + logger.debug("Attempting to connect to Docker Engine at \(socketPath)") + + // Check if socket file exists + guard FileManager.default.fileExists(atPath: socketPath) else { + logger.error("Docker socket not found at \(socketPath)") + throw DockerEngineError.socketNotFound(path: socketPath) + } + + // Try to ping the Docker daemon + let version = try await getVersion() + logger.info("Successfully connected to Docker Engine version: \(version)") + + return true + } + + /// Get Docker Engine version information + /// - Returns: Version string + /// - Throws: Error if request fails + public func getVersion() async throws -> String { + let response = try await makeRequest(path: "/version") + + guard let data = response.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let version = json["Version"] as? String else { + throw DockerEngineError.invalidResponse + } + + return version + } + + /// Make an HTTP request to the Docker daemon via Unix socket + /// - Parameters: + /// - path: API endpoint path + /// - method: HTTP method (default: GET) + /// - Returns: Response body as string + /// - Throws: Error if request fails + private func makeRequest(path: String, method: String = "GET") async throws -> String { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { + try? eventLoopGroup.syncShutdownGracefully() + } + + let bootstrap = ClientBootstrap(group: eventLoopGroup) + + do { + let channel = try await bootstrap.connect(unixDomainSocketPath: socketPath).get() + defer { + try? channel.close().wait() + } + + // Build HTTP request + let request = """ + \(method) \(path) HTTP/1.1\r + Host: localhost\r + Accept: application/json\r + Connection: close\r + \r + + """ + + var buffer = channel.allocator.buffer(capacity: request.utf8.count) + buffer.writeString(request) + try await channel.writeAndFlush(buffer).get() + + logger.debug("Request sent, awaiting response") + + // Read response with timeout + // Note: This is a simplified implementation that reads until connection close + // A production implementation should use proper HTTP response parsing + var responseData = Data() + var attempts = 0 + + while attempts < Self.responseTimeoutAttempts { + do { + if let data = try channel.readInbound(as: ByteBuffer.self) { + responseData.append(contentsOf: data.readableBytesView) + } else { + // No more data available + try? await Task.sleep(for: .milliseconds(Self.responseReadDelayMs)) + attempts += 1 + } + } catch { + break + } + } + + // Parse HTTP response to extract body + guard let responseString = String(data: responseData, encoding: .utf8) else { + throw DockerEngineError.invalidResponse + } + + // Simple HTTP response parsing - split headers and body + let parts = responseString.components(separatedBy: "\r\n\r\n") + guard parts.count >= 2 else { + // If we can't parse properly, check if we at least got JSON + if responseString.contains("{") { + if let jsonStart = responseString.firstIndex(of: "{"), + let jsonEnd = responseString.lastIndex(of: "}") { + return String(responseString[jsonStart...jsonEnd]) + } + } + throw DockerEngineError.invalidResponse + } + + return parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + + } catch { + logger.error("Failed to make request to Docker daemon: \(error)") + throw DockerEngineError.connectionFailed(String(describing: error)) + } + } +} + +/// Errors that can occur when connecting to Docker Engine +public enum DockerEngineError: Error, CustomStringConvertible { + case socketNotFound(path: String) + case connectionFailed(String) + case invalidResponse + case requestFailed(String) + + public var description: String { + switch self { + case .socketNotFound(let path): + return "Docker socket not found at path: \(path)" + case .connectionFailed(let message): + return "Failed to connect to Docker Engine: \(message)" + case .invalidResponse: + return "Received invalid response from Docker Engine" + case .requestFailed(let message): + return "Docker Engine request failed: \(message)" + } + } +} diff --git a/Tests/ContainerAPIClientTests/DockerEngineClientTests.swift b/Tests/ContainerAPIClientTests/DockerEngineClientTests.swift new file mode 100644 index 000000000..917e0c291 --- /dev/null +++ b/Tests/ContainerAPIClientTests/DockerEngineClientTests.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +@testable import ContainerAPIClient + +struct DockerEngineClientTests { + + @Test("Initialize DockerEngineClient with default socket path") + func testDefaultInitialization() { + let client = DockerEngineClient() + #expect(client.socketPath == "/var/run/docker.sock") + } + + @Test("Initialize DockerEngineClient with custom socket path") + func testCustomSocketPath() { + let customPath = "/custom/docker.sock" + let client = DockerEngineClient(socketPath: customPath) + #expect(client.socketPath == customPath) + } + + @Test("Connect fails when socket doesn't exist") + func testConnectWithNonexistentSocket() async { + let client = DockerEngineClient(socketPath: "/nonexistent/docker.sock") + + await #expect(throws: DockerEngineError.self) { + try await client.connect() + } + } + + @Test("DockerEngineError descriptions") + func testErrorDescriptions() { + let socketNotFoundError = DockerEngineError.socketNotFound(path: "/test/path") + #expect(socketNotFoundError.description.contains("/test/path")) + + let connectionFailedError = DockerEngineError.connectionFailed("test reason") + #expect(connectionFailedError.description.contains("test reason")) + + let invalidResponseError = DockerEngineError.invalidResponse + #expect(!invalidResponseError.description.isEmpty) + + let requestFailedError = DockerEngineError.requestFailed("test error") + #expect(requestFailedError.description.contains("test error")) + } +} diff --git a/docs/docker-engine-client-example.md b/docs/docker-engine-client-example.md new file mode 100644 index 000000000..345080f1f --- /dev/null +++ b/docs/docker-engine-client-example.md @@ -0,0 +1,93 @@ +# Docker Engine Client Example + +This example demonstrates how to use the `DockerEngineClient` to connect to a Docker daemon. + +## Overview + +The `DockerEngineClient` provides a simple way to connect to Docker Engine daemons via Unix sockets. This enables interoperability between the `container` tool and Docker. + +**Important**: This is a minimal implementation suitable for basic connectivity checking and version retrieval. For production use or high-frequency requests, consider enhancements such as: +- Proper HTTP response parsing with chunked transfer encoding support +- NIO-based async response handling instead of polling +- Connection pooling and reuse +- Comprehensive error handling for edge cases + +## Basic Usage + +```swift +import ContainerAPIClient +import Logging + +let logger = Logger(label: "docker-client-example") + +// Create a client with the default socket path +let client = DockerEngineClient(logger: logger) + +// Or use a custom socket path +// let client = DockerEngineClient(socketPath: "/custom/docker.sock", logger: logger) + +do { + // Connect to the Docker daemon + let connected = try await client.connect() + + if connected { + print("Successfully connected to Docker Engine") + + // Get Docker version + let version = try await client.getVersion() + print("Docker Engine version: \(version)") + } +} catch DockerEngineError.socketNotFound(let path) { + print("Docker socket not found at: \(path)") + print("Make sure Docker is installed and running.") +} catch { + print("Error connecting to Docker: \(error)") +} +``` + +## Error Handling + +The client provides specific error types for better error handling: + +```swift +do { + try await client.connect() +} catch DockerEngineError.socketNotFound(let path) { + print("Socket not found: \(path)") +} catch DockerEngineError.connectionFailed(let message) { + print("Connection failed: \(message)") +} catch DockerEngineError.invalidResponse { + print("Received invalid response from Docker") +} catch DockerEngineError.requestFailed(let message) { + print("Request failed: \(message)") +} catch { + print("Unexpected error: \(error)") +} +``` + +## Requirements + +- macOS 15 or later +- Swift 6.2 or later +- Docker installed and running (for actual connectivity) + +## Running on macOS + +Since this is part of the `container` tool that requires macOS with Apple silicon, you'll need: + +1. macOS 15+ (macOS 26 recommended) +2. Apple silicon Mac +3. Xcode 26 + +Build and run from the repository root: + +```bash +swift build +``` + +## Use Cases + +- Checking if Docker is available on the system +- Getting Docker version information +- Enabling tools to work with both `container` and Docker +- Building hybrid applications that support multiple container runtimes diff --git a/docs/how-to.md b/docs/how-to.md index d6957042a..1aee91980 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -514,6 +514,40 @@ The `container system logs` command allows you to look at the log messages that % +## Connect to Docker Engine + +The `container` tool includes a `DockerEngineClient` API that can connect to Docker Engine daemons running on the same system. This enables interoperability scenarios where you need to interact with both `container` and Docker. + +### Using DockerEngineClient in Swift + +If you're building applications or tools using the `container` Swift package, you can use the `DockerEngineClient` to connect to a Docker daemon: + +```swift +import ContainerAPIClient +import Logging + +let logger = Logger(label: "my-app") +let client = DockerEngineClient(socketPath: "/var/run/docker.sock", logger: logger) + +do { + let connected = try await client.connect() + if connected { + let version = try await client.getVersion() + print("Connected to Docker Engine version: \(version)") + } +} catch { + print("Failed to connect to Docker Engine: \(error)") +} +``` + +### Socket Path Configuration + +By default, `DockerEngineClient` connects to `/var/run/docker.sock`, which is the standard Docker daemon socket path on Unix systems. If your Docker daemon uses a different socket path, you can specify it when creating the client: + +```swift +let client = DockerEngineClient(socketPath: "/custom/path/docker.sock") +``` + ## Generating and installing completion scripts ### Overview diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 715c2db0b..2f17a4fcb 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -48,6 +48,10 @@ When `container-apiserver` starts, it launches an XPC helper `container-core-ima ![diagram showing `container` functional organization](/docs/assets/functional-model-light.svg) +## Docker Engine Connectivity + +The `container` tool includes a `DockerEngineClient` that enables connectivity with Docker Engine daemons. This client can connect to Docker daemons via Unix sockets (typically `/var/run/docker.sock`) to retrieve version information and verify connectivity. This feature facilitates interoperability scenarios where you need to interact with both the `container` tool and Docker Engine on the same system. + ## What limitations does `container` have today? With the initial release of `container`, you get basic facilities for building and running containers, but many common containerization features remain to be implemented. Consider [contributing](../CONTRIBUTING.md) new features and bug fixes to `container` and the Containerization projects!