Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Sources/ContainerResource/Network/NetworkConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,56 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
}
}

/// Intermediate representation for loading network configuration from a JSON file.
///
/// Only `mode` is required. All other fields have sensible defaults.
/// The network name (ID) is provided separately via the CLI argument, not the file.
public struct NetworkConfigurationFile: Codable, Sendable {
/// The network type (e.g. "nat", "hostOnly").
public let mode: NetworkMode

/// The preferred CIDR address for the IPv4 subnet, if specified.
public let ipv4Subnet: String?

/// The preferred CIDR address for the IPv6 subnet, if specified.
public let ipv6Subnet: String?

/// Key-value labels for the network.
public let labels: [String: String]?

/// Details about the network plugin that manages this network.
public let pluginInfo: NetworkPluginInfo?

public init(
mode: NetworkMode,
ipv4Subnet: String? = nil,
ipv6Subnet: String? = nil,
labels: [String: String]? = nil,
pluginInfo: NetworkPluginInfo? = nil
) {
self.mode = mode
self.ipv4Subnet = ipv4Subnet
self.ipv6Subnet = ipv6Subnet
self.labels = labels
self.pluginInfo = pluginInfo
}

/// Convert to a full ``NetworkConfiguration`` using the supplied network ID.
public func toNetworkConfiguration(id: String) throws -> NetworkConfiguration {
let v4 = try ipv4Subnet.map { try CIDRv4($0) }
let v6 = try ipv6Subnet.map { try CIDRv6($0) }
let plugin = pluginInfo ?? NetworkPluginInfo(plugin: "container-network-vmnet")
return try NetworkConfiguration(
id: id,
mode: mode,
ipv4Subnet: v4,
ipv6Subnet: v6,
labels: labels ?? [:],
pluginInfo: plugin
)
}
}

extension String {
/// Ensure that the network ID has the correct syntax.
fileprivate func isValidNetworkID() -> Bool {
Expand Down
53 changes: 45 additions & 8 deletions Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extension NetworkVmnetHelper {
var id: String

@Option(name: .long, help: "Network mode")
var mode: NetworkMode = .nat
var mode: NetworkMode?

@Option(name: .customLong("subnet"), help: "CIDR address for the IPv4 subnet")
var ipv4Subnet: String?
Expand All @@ -57,12 +57,21 @@ extension NetworkVmnetHelper {
var ipv6Subnet: String?

@Option(name: .long, help: "Variant of the network helper to use.")
var variant: Variant = {
var variant: Variant?

@Option(
name: .long, help: "Path to a JSON configuration file for the network", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
var config: String?

private static var defaultVariant: Variant {
guard #available(macOS 26, *) else {
return .allocationOnly
}
return .reserved
}()
}

func run() async throws {
let commandName = NetworkVmnetHelper._commandName
Expand All @@ -74,23 +83,51 @@ extension NetworkVmnetHelper {

do {
log.info("configuring XPC server")
let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) }
let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) }

let effectiveMode: NetworkMode
let effectiveSubnet: String?
let effectiveSubnetV6: String?
let effectiveVariant: Variant

if let configPath = config {
let data = try Data(contentsOf: URL(fileURLWithPath: configPath))
let configFile = try JSONDecoder().decode(NetworkConfigurationFile.self, from: data)

// CLI flags override values from the config file.
effectiveMode = mode ?? configFile.mode
effectiveSubnet = ipv4Subnet ?? configFile.ipv4Subnet
effectiveSubnetV6 = ipv6Subnet ?? configFile.ipv6Subnet
if let v = variant {
effectiveVariant = v
} else if let v = configFile.pluginInfo?.variant, let parsed = Variant(rawValue: v) {
effectiveVariant = parsed
} else {
effectiveVariant = Self.defaultVariant
}
} else {
effectiveMode = mode ?? .nat
effectiveSubnet = ipv4Subnet
effectiveSubnetV6 = ipv6Subnet
effectiveVariant = variant ?? Self.defaultVariant
}

let ipv4Subnet = try effectiveSubnet.map { try CIDRv4($0) }
let ipv6Subnet = try effectiveSubnetV6.map { try CIDRv6($0) }
let pluginInfo = NetworkPluginInfo(
plugin: NetworkVmnetHelper._commandName,
variant: self.variant.rawValue
variant: effectiveVariant.rawValue
)

let configuration = try NetworkConfiguration(
id: id,
mode: mode,
mode: effectiveMode,
ipv4Subnet: ipv4Subnet,
ipv6Subnet: ipv6Subnet,
pluginInfo: pluginInfo
)
let network = try Self.createNetwork(
configuration: configuration,
variant: self.variant,
variant: effectiveVariant,
log: log
)
try await network.start()
Expand Down
80 changes: 80 additions & 0 deletions Tests/ContainerResourceTests/NetworkConfigurationTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ContainerizationError
import ContainerizationExtras
import Foundation
import Testing

@testable import ContainerResource
Expand Down Expand Up @@ -132,4 +133,83 @@ struct NetworkConfigurationTest {
}
}

// MARK: - NetworkConfigurationFile tests

@Test func testConfigFileDecodeMinimal() throws {
let json = """
{"mode": "nat"}
"""
let data = json.data(using: .utf8)!
let configFile = try JSONDecoder().decode(NetworkConfigurationFile.self, from: data)
#expect(configFile.mode == .nat)
#expect(configFile.ipv4Subnet == nil)
#expect(configFile.ipv6Subnet == nil)
#expect(configFile.labels == nil)
#expect(configFile.pluginInfo == nil)

let config = try configFile.toNetworkConfiguration(id: "test-net")
#expect(config.id == "test-net")
#expect(config.mode == .nat)
#expect(config.ipv4Subnet == nil)
#expect(config.ipv6Subnet == nil)
#expect(config.labels == [:])
#expect(config.pluginInfo?.plugin == "container-network-vmnet")
}

@Test func testConfigFileDecodeComplete() throws {
let json = """
{
"mode": "hostOnly",
"ipv4Subnet": "192.168.64.0/24",
"ipv6Subnet": "fd00::/64",
"labels": {"env": "production"},
"pluginInfo": {"plugin": "my-plugin", "variant": "shared"}
}
"""
let data = json.data(using: .utf8)!
let configFile = try JSONDecoder().decode(NetworkConfigurationFile.self, from: data)
#expect(configFile.mode == .hostOnly)
#expect(configFile.ipv4Subnet == "192.168.64.0/24")
#expect(configFile.ipv6Subnet == "fd00::/64")
#expect(configFile.labels == ["env": "production"])
#expect(configFile.pluginInfo?.plugin == "my-plugin")
#expect(configFile.pluginInfo?.variant == "shared")

let config = try configFile.toNetworkConfiguration(id: "full-net")
#expect(config.id == "full-net")
#expect(config.mode == .hostOnly)
#expect(config.ipv4Subnet != nil)
#expect(config.ipv6Subnet != nil)
#expect(config.labels == ["env": "production"])
#expect(config.pluginInfo?.plugin == "my-plugin")
#expect(config.pluginInfo?.variant == "shared")
}

@Test func testConfigFileInvalidSubnet() throws {
let json = """
{"mode": "nat", "ipv4Subnet": "not-a-cidr"}
"""
let data = json.data(using: .utf8)!
let configFile = try JSONDecoder().decode(NetworkConfigurationFile.self, from: data)
#expect(throws: (any Error).self) {
_ = try configFile.toNetworkConfiguration(id: "test-net")
}
}

@Test func testConfigFileInvalidId() throws {
let json = """
{"mode": "nat"}
"""
let data = json.data(using: .utf8)!
let configFile = try JSONDecoder().decode(NetworkConfigurationFile.self, from: data)
#expect {
_ = try configFile.toNetworkConfiguration(id: "INVALID-ID")
} throws: { error in
guard let err = error as? ContainerizationError else { return false }
#expect(err.code == .invalidArgument)
#expect(err.message.starts(with: "invalid network ID"))
return true
}
}

}