diff --git a/Sources/ContainerResource/Network/NetworkConfiguration.swift b/Sources/ContainerResource/Network/NetworkConfiguration.swift index a5e277cae..02d47b9af 100644 --- a/Sources/ContainerResource/Network/NetworkConfiguration.swift +++ b/Sources/ContainerResource/Network/NetworkConfiguration.swift @@ -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 { diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift index e1aa3d62a..662616cab 100644 --- a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -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? @@ -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 @@ -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() diff --git a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift index a31e35044..e8b0b5a57 100644 --- a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift +++ b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift @@ -16,6 +16,7 @@ import ContainerizationError import ContainerizationExtras +import Foundation import Testing @testable import ContainerResource @@ -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 + } + } + }