Use NetworkResource for network management in API server. (#1421)

- Part of #1404.
- Evolves API to use NetworkResource that conforms to ManagedResource.
- BREAKING CHANGE - compile time impact due to changes to Swift client
API signatures. No change to persistent data or API server XPC
protocols.
This commit is contained in:
J Logan
2026-04-30 11:06:50 -07:00
committed by GitHub
parent 472cb1950f
commit b1f3c0d35b
19 changed files with 293 additions and 102 deletions
@@ -272,7 +272,7 @@ extension Application {
guard let defaultNetwork = try await networkClient.builtin else {
throw ContainerizationError(.invalidState, message: "default network is not present")
}
guard case .running(_, _) = defaultNetwork else {
guard defaultNetwork.status.phase == "running" else {
throw ContainerizationError(.invalidState, message: "default network is not running")
}
config.networks = [
@@ -74,8 +74,8 @@ extension Application {
pluginInfo: NetworkPluginInfo(plugin: self.plugin, variant: self.pluginVariant)
)
let networkClient = NetworkClient()
let state = try await networkClient.create(configuration: config)
print(state.id)
let network = try await networkClient.create(configuration: config)
print(network.id)
}
}
}
@@ -53,7 +53,7 @@ extension Application {
public mutating func run() async throws {
let networkClient = NetworkClient()
let uniqueNetworkNames = Set<String>(networkNames)
let networks: [NetworkState]
let networks: [NetworkResource]
if all {
networks = try await networkClient.list()
@@ -91,7 +91,7 @@ extension Application {
var failed = [String]()
let _log = log
try await withThrowingTaskGroup(of: NetworkState?.self) { group in
try await withThrowingTaskGroup(of: NetworkResource?.self) { group in
for network in networks {
group.addTask {
do {
@@ -17,7 +17,6 @@
import ArgumentParser
import ContainerAPIClient
import Foundation
import SwiftProtobuf
extension Application {
public struct NetworkInspect: AsyncLoggableCommand {
@@ -35,11 +34,7 @@ extension Application {
public func run() async throws {
let networkClient = NetworkClient()
let items = try await networkClient.list().filter {
networks.contains($0.id)
}.map {
PrintableNetwork($0)
}
let items = try await networkClient.list().filter { networks.contains($0.id) }
try Output.emit(Output.renderJSON(items))
}
}
@@ -16,10 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationExtras
import Foundation
import SwiftProtobuf
extension Application {
public struct NetworkList: AsyncLoggableCommand {
@@ -42,45 +39,7 @@ extension Application {
public func run() async throws {
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)
}
}
}
extension PrintableNetwork: ListDisplayable {
public static var tableHeader: [String] {
["NETWORK", "STATE", "SUBNET"]
}
public var tableRow: [String] {
if let status {
return [self.id, self.state, status.ipv4Subnet.description]
}
return [self.id, self.state, "none"]
}
public var quietValue: String {
self.id
}
}
public struct PrintableNetwork: Codable, Sendable {
let id: String
let state: String
let config: NetworkConfiguration
let status: NetworkStatus?
public init(_ network: NetworkState) {
self.id = network.id
self.state = network.state
switch network {
case .created(let config):
self.config = config
self.status = nil
case .running(let config, let status):
self.config = config
self.status = status
try Output.render(json: networks, display: networks, format: format, quiet: quiet)
}
}
}
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// 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
extension NetworkResource: ListDisplayable {
public static var tableHeader: [String] {
["NETWORK", "STATE", "SUBNET"]
}
public var tableRow: [String] {
[id, status.phase, status.ipv4Subnet?.description ?? "none"]
}
public var quietValue: String {
id
}
}
@@ -119,16 +119,8 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
}
private func validate() throws {
guard id.isValidNetworkID() else {
guard NetworkResource.nameValid(id) else {
throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)")
}
}
}
extension String {
/// Ensure that the network ID has the correct syntax.
fileprivate func isValidNetworkID() -> Bool {
let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"#
return self.range(of: pattern, options: .regularExpression) != nil
}
}
@@ -0,0 +1,116 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationExtras
import Foundation
/// A network resource, representing a configured virtual network and its runtime status.
///
/// `NetworkResource` conforms to `ManagedResource` and separates the network's
/// intrinsic configuration from its ephemeral runtime status following the same
/// config/status split used by Kubernetes and Docker. `config` is persisted;
/// `status` reflects what the network plugin reports at runtime.
///
/// The JSON encoding uses a single `status` object containing a `phase` field
/// alongside any runtime-allocated address properties, replacing the prior flat
/// `state`/`status` pair in the CLI output.
public struct NetworkResource: ManagedResource {
/// The network's configuration its persistent, intrinsic properties.
public let config: NetworkConfiguration
/// The network's current status, including lifecycle phase and any
/// runtime-allocated address properties.
public let status: NetworkStatus
// MARK: ManagedResource
/// The unique identifier for this network. Identical to ``config/id``.
public var id: String { config.id }
/// The user-assigned name for this network. For networks, name and ID are the same.
public var name: String { config.id }
/// The time at which this network was created.
public var creationDate: Date { config.creationDate }
/// Key-value labels for this network.
public var labels: ResourceLabels { config.labels }
/// Returns `true` for a system-managed network that cannot be deleted by the user.
public var isBuiltin: Bool { labels.isBuiltin }
/// Returns `true` if `name` is a syntactically valid network identifier.
///
/// Valid network names are lowercase alphanumeric strings of up to 63
/// characters, allowing dots, hyphens, and underscores in interior positions.
public static func nameValid(_ name: String) -> Bool {
let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"#
return name.range(of: pattern, options: .regularExpression) != nil
}
// MARK: Initialization
/// Creates a network resource.
///
/// - Parameters:
/// - config: The network's intrinsic configuration.
/// - networkStatus: The plugin-reported runtime status, or `nil` if the
/// network is not yet running.
public init(config: NetworkConfiguration, networkStatus: NetworkPluginStatus? = nil) {
self.config = config
self.status = networkStatus.map { NetworkStatus(running: $0) } ?? .created
}
}
// MARK: - Conversion from NetworkState
extension NetworkResource {
/// Creates a network resource from a ``NetworkState``.
///
/// Used when translating from the internal plugin-protocol type to the
/// public API surface type.
public init(_ networkState: NetworkState) {
switch networkState {
case .created(let config):
self.init(config: config)
case .running(let config, let status):
self.init(config: config, networkStatus: status)
}
}
}
// MARK: - Codable
extension NetworkResource {
enum CodingKeys: String, CodingKey {
case id
case config
case status
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(config, forKey: .config)
try container.encode(status, forKey: .status)
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.config = try container.decode(NetworkConfiguration.self, forKey: .config)
self.status = try container.decode(NetworkStatus.self, forKey: .status)
}
}
@@ -17,7 +17,7 @@
import ContainerizationExtras
import Foundation
public struct NetworkStatus: Codable, Sendable {
public struct NetworkPluginStatus: Codable, Sendable {
/// The address allocated for the network if no subnet was specified at
/// creation time; otherwise, the subnet from the configuration.
public let ipv4Subnet: CIDRv4
@@ -83,7 +83,7 @@ public enum NetworkState: Codable, Sendable {
// The network has been configured.
case created(NetworkConfiguration)
// The network is running.
case running(NetworkConfiguration, NetworkStatus)
case running(NetworkConfiguration, NetworkPluginStatus)
public var state: String {
switch self {
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationExtras
import Foundation
/// The runtime status of a network resource.
///
/// `phase` names the current lifecycle stage; the address fields are present
/// only when `phase` is `"running"` and are `nil` otherwise. Clients should
/// treat unrecognised `phase` values as unknown forward-compatible stages rather
/// than treating them as errors.
public struct NetworkStatus: Codable, Sendable {
/// The current lifecycle phase of the network.
///
/// Defined values: `"created"` (configured, plugin not yet active) and
/// `"running"` (plugin active, subnet and gateway assigned).
public let phase: String
/// The allocated IPv4 subnet. Present only when `phase` is `"running"`.
public let ipv4Subnet: CIDRv4?
/// The IPv4 gateway address. Present only when `phase` is `"running"`.
public let ipv4Gateway: IPv4Address?
/// The allocated IPv6 subnet. Present only when `phase` is `"running"` and
/// the network has IPv6 enabled.
public let ipv6Subnet: CIDRv6?
public init(
phase: String,
ipv4Subnet: CIDRv4? = nil,
ipv4Gateway: IPv4Address? = nil,
ipv6Subnet: CIDRv6? = nil
) {
self.phase = phase
self.ipv4Subnet = ipv4Subnet
self.ipv4Gateway = ipv4Gateway
self.ipv6Subnet = ipv6Subnet
}
}
extension NetworkStatus {
/// The status value for a network that is configured but not yet running.
public static let created = NetworkStatus(phase: "created")
/// Creates a running-phase status from a ``NetworkPluginStatus``.
init(running networkStatus: NetworkPluginStatus) {
self.init(
phase: "running",
ipv4Subnet: networkStatus.ipv4Subnet,
ipv4Gateway: networkStatus.ipv4Gateway,
ipv6Subnet: networkStatus.ipv6Subnet
)
}
}
@@ -29,9 +29,9 @@ import Foundation
///
/// ```swift
/// let client = NetworkClient()
/// let state = try await client.create(configuration: config)
/// let network = try await client.create(configuration: config)
/// let networks = try await client.list()
/// try await client.delete(id: state.id)
/// try await client.delete(id: network.id)
/// ```
public struct NetworkClient: Sendable {
/// The Mach service name used to locate the container API server.
@@ -67,13 +67,13 @@ public struct NetworkClient: Sendable {
/// 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.
/// returns a ``NetworkResource`` 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 {
/// network resource, or if the underlying XPC call fails.
public func create(configuration: NetworkConfiguration) async throws -> NetworkResource {
let request = XPCMessage(route: .networkCreate)
request.set(key: .networkId, value: configuration.id)
@@ -81,38 +81,51 @@ public struct NetworkClient: Sendable {
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")
// Prefer current encoding ( 0.12.0 server).
if let resourceData = response.dataNoCopy(key: .networkResource) {
return try JSONDecoder().decode(NetworkResource.self, from: resourceData)
}
let state = try JSONDecoder().decode(NetworkState.self, from: responseData)
return state
// Fall back to pre-0.12.0 server: decode NetworkState and convert.
if let stateData = response.dataNoCopy(key: .networkState) {
let state = try JSONDecoder().decode(NetworkState.self, from: stateData)
return NetworkResource(state)
}
throw ContainerizationError(.invalidArgument, message: "network configuration not received")
}
/// Returns the current state of all networks known to the API server.
///
/// - Returns: An array of ``NetworkState`` values, or an empty array if no
/// - Returns: An array of ``NetworkResource`` 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] {
public func list() async throws -> [NetworkResource] {
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 []
// Prefer current encoding ( 0.12.0 server).
if let resourceData = response.dataNoCopy(key: .networkResources) {
return try JSONDecoder().decode([NetworkResource].self, from: resourceData)
}
let states = try JSONDecoder().decode([NetworkState].self, from: responseData)
return states
// Fall back to pre-0.12.0 server: decode NetworkState and convert.
if let stateData = response.dataNoCopy(key: .networkStates) {
return try JSONDecoder().decode([NetworkState].self, from: stateData).map(NetworkResource.init)
}
return []
}
/// Returns the network with the given identifier.
///
/// - Parameter id: The identifier of the network to look up.
/// - Returns: The ``NetworkState`` for the matching network.
/// - Returns: The ``NetworkResource`` 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 {
public func get(id: String) async throws -> NetworkResource {
let networks = try await list()
guard let network = networks.first(where: { $0.id == id }) else {
throw ContainerizationError(.notFound, message: "network \(id) not found")
@@ -141,7 +154,7 @@ public struct NetworkClient: Sendable {
/// built-in resource labels.
///
/// - Throws: ``ContainerizationError`` if the underlying XPC call fails.
public var builtin: NetworkState? {
public var builtin: NetworkResource? {
get async throws {
try await list().first { $0.isBuiltin }
}
@@ -209,8 +209,8 @@ public struct Utility {
networks: parsedNetworks
)
for attachmentConfiguration in config.networks {
let network: NetworkState = try await networkClient.get(id: attachmentConfiguration.network)
guard case .running(_, _) = network else {
let network = try await networkClient.get(id: attachmentConfiguration.network)
guard network.status.phase == "running" else {
throw ContainerizationError(.invalidState, message: "network \(attachmentConfiguration.network) is not running")
}
}
@@ -103,6 +103,11 @@ public enum XPCKeys: String {
case networkConfig
case networkState
case networkStates
// Added in 0.12.0: NetworkResource encoding (status.phase shape).
// DEPRECATED 0.12.0: networkState/networkStates retained for down-revision
// client compatibility; remove at next major version boundary.
case networkResource
case networkResources
/// Kernel
case kernel
@@ -32,11 +32,18 @@ public struct NetworksHarness: Sendable {
@Sendable
public func list(_ message: XPCMessage) async throws -> XPCMessage {
let containers = try await service.list()
let data = try JSONEncoder().encode(containers)
let states = try await service.list()
let reply = message.reply()
reply.set(key: .networkStates, value: data)
// Current encoding: NetworkResource with status.phase shape ( 0.12.0).
let resources = states.map(NetworkResource.init)
reply.set(key: .networkResources, value: try JSONEncoder().encode(resources))
// DEPRECATED 0.12.0 retained for down-revision client compatibility.
// Remove at next major version boundary.
reply.set(key: .networkStates, value: try JSONEncoder().encode(states))
return reply
}
@@ -50,10 +57,15 @@ public struct NetworksHarness: Sendable {
let config = try JSONDecoder().decode(NetworkConfiguration.self, from: data)
let networkState = try await service.create(configuration: config)
let networkData = try JSONEncoder().encode(networkState)
let reply = message.reply()
reply.set(key: .networkState, value: networkData)
// Current encoding: NetworkResource with status.phase shape ( 0.12.0).
reply.set(key: .networkResource, value: try JSONEncoder().encode(NetworkResource(networkState)))
// DEPRECATED 0.12.0 retained for down-revision client compatibility.
// Remove at next major version boundary.
reply.set(key: .networkState, value: try JSONEncoder().encode(networkState))
return reply
}
@@ -70,7 +70,7 @@ public actor AllocationOnlyVmnetNetwork: Network {
let ipv4Subnet = configuration.ipv4Subnet ?? Self.defaultIPv4Subnet
let gateway = IPv4Address(ipv4Subnet.lower.value + 1)
let status = NetworkStatus(
let status = NetworkPluginStatus(
ipv4Subnet: ipv4Subnet,
ipv4Gateway: gateway,
ipv6Subnet: nil,
@@ -80,7 +80,7 @@ public final class ReservedVmnetNetwork: Network {
let networkInfo = try startNetwork(configuration: configuration, log: log)
let networkStatus = NetworkStatus(
let networkStatus = NetworkPluginStatus(
ipv4Subnet: networkInfo.ipv4Subnet,
ipv4Gateway: networkInfo.ipv4Gateway,
ipv6Subnet: networkInfo.ipv6Subnet,
@@ -917,10 +917,10 @@ public actor SandboxService {
let networkClient = NetworkClient()
for allocatedAttach in allocatedAttachments {
let state = try await networkClient.get(id: allocatedAttach.attachment.network)
guard case .running(_, let status) = state else {
guard state.status.phase == "running", let gateway = state.status.ipv4Gateway else {
continue
}
return [status.ipv4Gateway.description]
return [gateway.description]
}
return []
+1 -2
View File
@@ -46,9 +46,8 @@ class CLITest {
struct NetworkInspectOutput: Codable {
let id: String
let state: String
let config: NetworkConfiguration
let status: NetworkStatus?
let status: NetworkStatus
}
let testName: String
@@ -236,13 +236,13 @@ struct PrintableContainerDisplayTests {
}
}
// MARK: - PrintableNetwork conformance tests
// MARK: - NetworkResource ListDisplayable conformance tests
struct PrintableNetworkDisplayTests {
struct NetworkResourceDisplayTests {
@Test
func tableHeaderHasThreeColumns() {
#expect(PrintableNetwork.tableHeader.count == 3)
#expect(PrintableNetwork.tableHeader == ["NETWORK", "STATE", "SUBNET"])
#expect(NetworkResource.tableHeader.count == 3)
#expect(NetworkResource.tableHeader == ["NETWORK", "STATE", "SUBNET"])
}
}