From 38f68d79107a2ae530d3896eea1e5c8af946cef6 Mon Sep 17 00:00:00 2001 From: Apoorv Darshan Date: Wed, 18 Feb 2026 21:58:43 +0530 Subject: [PATCH] Support loading network configuration from file (#1047) Add --config option to `container-network-vmnet start` that accepts a path to a JSON configuration file. CLI flags override values from the config file when both are provided. Introduces NetworkConfigurationFile as an intermediate Codable struct that is decoded from the JSON file and converted to a full NetworkConfiguration via toNetworkConfiguration(id:). --- .../Network/NetworkConfiguration.swift | 50 ++++++++++++ .../NetworkVmnetHelper+Start.swift | 53 ++++++++++-- .../NetworkConfigurationTest.swift | 80 +++++++++++++++++++ 3 files changed, 175 insertions(+), 8 deletions(-) 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 + } + } + }