diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 7cf35ed5..b06285d0 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -314,7 +314,7 @@ extension APIServer { if defaultNetwork == nil { // FIXME: default network should be configurable elsewhere let config = try NetworkConfiguration( - id: ClientNetwork.defaultNetworkName, + id: NetworkClient.defaultNetworkName, mode: .nat, labels: try .init([ResourceLabelKeys.role: ResourceRoleValues.builtin]), pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet") diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 4794a45b..be7ff169 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -265,7 +265,8 @@ extension Application { // Enable Rosetta only if the user didn't ask to disable it config.rosetta = useRosetta - guard let defaultNetwork = try await ClientNetwork.builtin else { + let networkClient = NetworkClient() + guard let defaultNetwork = try await networkClient.builtin else { throw ContainerizationError(.invalidState, message: "default network is not present") } guard case .running(_, _) = defaultNetwork else { diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index bb6fef24..2c41fd78 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -73,7 +73,8 @@ extension Application { labels: parsedLabels, pluginInfo: NetworkPluginInfo(plugin: self.plugin, variant: self.pluginVariant) ) - let state = try await ClientNetwork.create(configuration: config) + let networkClient = NetworkClient() + let state = try await networkClient.create(configuration: config) print(state.id) } } diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift index 87d710b6..ea6ebbbc 100644 --- a/Sources/ContainerCommands/Network/NetworkDelete.swift +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -51,14 +51,15 @@ extension Application { } public mutating func run() async throws { + let networkClient = NetworkClient() let uniqueNetworkNames = Set(networkNames) let networks: [NetworkState] if all { - networks = try await ClientNetwork.list() + networks = try await networkClient.list() .filter { !$0.isBuiltin } } else { - networks = try await ClientNetwork.list() + networks = try await networkClient.list() .filter { c in guard uniqueNetworkNames.contains(c.id) else { return false @@ -96,7 +97,7 @@ extension Application { do { // Delete atomically disables the IP allocator, then deletes // the allocator. The disable fails if any IPs are still in use. - try await ClientNetwork.delete(id: network.id) + try await networkClient.delete(id: network.id) print(network.id) return nil } catch { diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift index 66898316..9bc954c5 100644 --- a/Sources/ContainerCommands/Network/NetworkInspect.swift +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -34,7 +34,8 @@ extension Application { public init() {} public func run() async throws { - let items = try await ClientNetwork.list().filter { + let networkClient = NetworkClient() + let items = try await networkClient.list().filter { networks.contains($0.id) }.map { PrintableNetwork($0) diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift index 0282f81b..4382befa 100644 --- a/Sources/ContainerCommands/Network/NetworkList.swift +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -40,7 +40,8 @@ extension Application { public init() {} public func run() async throws { - let networks = try await ClientNetwork.list() + let networkClient = NetworkClient() + let networks = try await networkClient.list() let items = networks.map { PrintableNetwork($0) } try Output.render(json: items, display: items, format: format, quiet: quiet) } diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift index a044bdb0..55748cf2 100644 --- a/Sources/ContainerCommands/Network/NetworkPrune.swift +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -30,9 +30,10 @@ extension Application.NetworkCommand { public var logOptions: Flags.Logging public func run() async throws { + let networkClient = NetworkClient() let client = ContainerClient() let allContainers = try await client.list() - let allNetworks = try await ClientNetwork.list() + let allNetworks = try await networkClient.list() var networksInUse = Set() for container in allContainers { @@ -49,7 +50,7 @@ extension Application.NetworkCommand { for network in networksToPrune { do { - try await ClientNetwork.delete(id: network.id) + try await networkClient.delete(id: network.id) prunedNetworks.append(network.id) } catch { // Note: This failure may occur due to a race condition between the network/ diff --git a/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift b/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift deleted file mode 100644 index ef701446..00000000 --- a/Sources/Services/ContainerAPIService/Client/ClientNetwork.swift +++ /dev/null @@ -1,97 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerResource -import ContainerXPC -import ContainerizationError -import ContainerizationExtras -import ContainerizationOS -import Foundation - -public struct ClientNetwork { - static let serviceIdentifier = "com.apple.container.apiserver" - - public static let defaultNetworkName = "default" - public static let noNetworkName = "none" -} - -extension ClientNetwork { - private static func newClient() -> XPCClient { - XPCClient(service: serviceIdentifier) - } - - private static func xpcSend( - client: XPCClient, - message: XPCMessage, - timeout: Duration? = XPCClient.xpcRegistrationTimeout - ) async throws -> XPCMessage { - try await client.send(message, responseTimeout: timeout) - } - - public static func create(configuration: NetworkConfiguration) async throws -> NetworkState { - let client = Self.newClient() - let request = XPCMessage(route: .networkCreate) - request.set(key: .networkId, value: configuration.id) - - let data = try JSONEncoder().encode(configuration) - request.set(key: .networkConfig, value: data) - - let response = try await xpcSend(client: client, message: request) - let responseData = response.dataNoCopy(key: .networkState) - guard let responseData else { - throw ContainerizationError(.invalidArgument, message: "network configuration not received") - } - let state = try JSONDecoder().decode(NetworkState.self, from: responseData) - return state - } - - public static func list() async throws -> [NetworkState] { - let client = Self.newClient() - let request = XPCMessage(route: .networkList) - - let response = try await xpcSend(client: client, message: request, timeout: .seconds(1)) - let responseData = response.dataNoCopy(key: .networkStates) - guard let responseData else { - return [] - } - let states = try JSONDecoder().decode([NetworkState].self, from: responseData) - return states - } - - /// Get the network for the provided id. - public static func get(id: String) async throws -> NetworkState { - let networks = try await list() - guard let network = networks.first(where: { $0.id == id }) else { - throw ContainerizationError(.notFound, message: "network \(id) not found") - } - return network - } - - /// Delete the network with the given id. - public static func delete(id: String) async throws { - let client = Self.newClient() - let request = XPCMessage(route: .networkDelete) - request.set(key: .networkId, value: id) - let _ = try await xpcSend(client: client, message: request) - } - - /// Retrieve the builtin network. - public static var builtin: NetworkState? { - get async throws { - try await list().first { $0.isBuiltin } - } - } -} diff --git a/Sources/Services/ContainerAPIService/Client/NetworkClient.swift b/Sources/Services/ContainerAPIService/Client/NetworkClient.swift new file mode 100644 index 00000000..fe2f4b0a --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/NetworkClient.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation + +/// A client for managing virtual networks through the container API server. +/// +/// `NetworkClient` communicates with `container-apiserver` over XPC to create, +/// list, inspect, and delete networks. Each instance holds a dedicated XPC +/// connection; create one client and reuse it across related operations. +/// +/// ```swift +/// let client = NetworkClient() +/// let state = try await client.create(configuration: config) +/// let networks = try await client.list() +/// try await client.delete(id: state.id) +/// ``` +public struct NetworkClient: Sendable { + /// The Mach service name used to locate the container API server. + /// + /// Pass a different value to ``init(serviceIdentifier:)`` to connect to an + /// alternative service endpoint, for example during testing. + public static let defaultServiceIdentifier = "com.apple.container.apiserver" + + /// The name of the default network created automatically on first use. + public static let defaultNetworkName = "default" + + /// The reserved name that indicates a container should have no network attachment. + public static let noNetworkName = "none" + + private let xpcClient: XPCClient + + /// Creates a new network client connected to the given service endpoint. + /// + /// - Parameter serviceIdentifier: The Mach service name of the API server. + /// Defaults to ``defaultServiceIdentifier``. + public init(serviceIdentifier: String = Self.defaultServiceIdentifier) { + self.xpcClient = XPCClient(service: serviceIdentifier) + } + + @discardableResult + private func xpcSend( + message: XPCMessage, + timeout: Duration? = XPCClient.xpcRegistrationTimeout + ) async throws -> XPCMessage { + try await xpcClient.send(message, responseTimeout: timeout) + } + + /// Creates a new network with the given configuration. + /// + /// The API server launches a network plugin instance for the new network and + /// returns a ``NetworkState`` reflecting the network once it is running. + /// + /// - Parameter configuration: The configuration describing the network to create. + /// - Returns: The running state of the newly created network. + /// - Throws: ``ContainerizationError`` if the server does not return a valid + /// network state, or if the underlying XPC call fails. + public func create(configuration: NetworkConfiguration) async throws -> NetworkState { + let request = XPCMessage(route: .networkCreate) + request.set(key: .networkId, value: configuration.id) + + let data = try JSONEncoder().encode(configuration) + request.set(key: .networkConfig, value: data) + + let response = try await xpcSend(message: request) + let responseData = response.dataNoCopy(key: .networkState) + guard let responseData else { + throw ContainerizationError(.invalidArgument, message: "network configuration not received") + } + let state = try JSONDecoder().decode(NetworkState.self, from: responseData) + return state + } + + /// Returns the current state of all networks known to the API server. + /// + /// - Returns: An array of ``NetworkState`` values, or an empty array if no + /// networks exist or the server returns no data. + /// - Throws: ``ContainerizationError`` if the underlying XPC call fails. + public func list() async throws -> [NetworkState] { + let request = XPCMessage(route: .networkList) + + let response = try await xpcSend(message: request, timeout: .seconds(1)) + let responseData = response.dataNoCopy(key: .networkStates) + guard let responseData else { + return [] + } + let states = try JSONDecoder().decode([NetworkState].self, from: responseData) + return states + } + + /// Returns the network with the given identifier. + /// + /// - Parameter id: The identifier of the network to look up. + /// - Returns: The ``NetworkState`` for the matching network. + /// - Throws: ``ContainerizationError/notFound`` if no network with the given + /// identifier exists, or a communication error if the XPC call fails. + public func get(id: String) async throws -> NetworkState { + let networks = try await list() + guard let network = networks.first(where: { $0.id == id }) else { + throw ContainerizationError(.notFound, message: "network \(id) not found") + } + return network + } + + /// Deletes the network with the given identifier. + /// + /// Deletion succeeds only when no containers are currently attached to the + /// network. The default network cannot be deleted. + /// + /// - Parameter id: The identifier of the network to delete. + /// - Throws: ``ContainerizationError`` if the network has active attachments, + /// if the network is the built-in default, or if the XPC call fails. + public func delete(id: String) async throws { + let request = XPCMessage(route: .networkDelete) + request.set(key: .networkId, value: id) + try await xpcSend(message: request) + } + + /// The built-in network, if one exists. + /// + /// The built-in network is created automatically on first use and cannot be + /// deleted. Returns `nil` if the API server cannot find a network with the + /// built-in resource labels. + /// + /// - Throws: ``ContainerizationError`` if the underlying XPC call fails. + public var builtin: NetworkState? { + get async throws { + try await list().first { $0.isBuiltin } + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index dcc68dbb..023ed2c7 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -195,20 +195,21 @@ public struct Utility { // Parse network specifications with properties let parsedNetworks = try management.networks.map { try Parser.network($0) } - if management.networks.contains(ClientNetwork.noNetworkName) { + if management.networks.contains(NetworkClient.noNetworkName) { guard management.networks.count == 1 else { - throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(ClientNetwork.noNetworkName)") + throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(NetworkClient.noNetworkName)") } config.networks = [] } else { - let builtinNetworkId = try await ClientNetwork.builtin?.id + let networkClient = NetworkClient() + let builtinNetworkId = try await networkClient.builtin?.id config.networks = try getAttachmentConfigurations( containerId: config.id, builtinNetworkId: builtinNetworkId, networks: parsedNetworks ) for attachmentConfiguration in config.networks { - let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) + let network: NetworkState = try await networkClient.get(id: attachmentConfiguration.network) guard case .running(_, _) = network else { throw ContainerizationError(.invalidState, message: "network \(attachmentConfiguration.network) is not running") } diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 6e9a1743..59ce902e 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -30,7 +30,7 @@ import Logging public actor NetworksService { struct NetworkServiceState { var networkState: NetworkState - var client: NetworkClient + var client: ContainerNetworkServiceClient.NetworkClient } private let pluginLoader: PluginLoader @@ -79,7 +79,7 @@ public actor NetworksService { for configuration in configurations { // Ensure the network with id "default" is marked as builtin. var updatedLabels: [String: String]? - if configuration.id == ClientNetwork.defaultNetworkName { + if configuration.id == NetworkClient.defaultNetworkName { let role = configuration.labels[ResourceLabelKeys.role] if role == nil || role != ResourceRoleValues.builtin { var labels = configuration.labels.dictionary @@ -201,7 +201,7 @@ public actor NetworksService { } //Ensure that the network is not named "none" - if configuration.id == ClientNetwork.noNetworkName { + if configuration.id == NetworkClient.noNetworkName { throw ContainerizationError(.unsupported, message: "network \(configuration.id) is not a valid name") } @@ -400,7 +400,7 @@ public actor NetworksService { return try await serviceState.client.deallocate(hostname: attachment.hostname) } - private static func getClient(configuration: NetworkConfiguration) throws -> NetworkClient { + private static func getClient(configuration: NetworkConfiguration) throws -> ContainerNetworkServiceClient.NetworkClient { guard let pluginInfo = configuration.pluginInfo else { throw ContainerizationError(.internalError, message: "network \(configuration.id) missing plugin information") } diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index b18e4234..dae76455 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -900,8 +900,9 @@ public actor SandboxService { } private func getDefaultNameservers(allocatedAttachments: [AllocatedAttachment]) async throws -> [String] { + let networkClient = NetworkClient() for allocatedAttach in allocatedAttachments { - let state = try await ClientNetwork.get(id: allocatedAttach.attachment.network) + let state = try await networkClient.get(id: allocatedAttach.attachment.network) guard case .running(_, let status) = state else { continue }