diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift index 2278fb380..3b6369aa0 100644 --- a/Sources/ContainerCommands/Network/NetworkCreate.swift +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -54,6 +54,41 @@ extension Application { }) var ipv6Subnet: CIDRv6? = nil + @Option( + name: .customLong("gateway"), + help: "Set the IPv4 gateway address for the network. Defaults to the first usable host address in --subnet.", + transform: { + try IPv4Address($0) + }) + var ipv4Gateway: IPv4Address? = nil + + @Option( + name: .customLong("ip-range"), + help: "Restrict dynamic IPv4 allocation to a sub-range of --subnet, expressed as CIDR.", + transform: { + try CIDRv4($0) + }) + var ipv4Range: CIDRv4? = nil + + @Option( + name: .customLong("aux-address"), + help: "Reserve a static hostname-to-IPv4 mapping (HOSTNAME=IPV4). Repeatable.") + var auxAddresses: [String] = [] + + @Option( + name: .customLong("driver-opt"), + help: "Set a free-form network driver option (KEY=VALUE). Repeatable. Currently informational; the vmnet plugin does not interpret any keys.") + var driverOpts: [String] = [] + + @Flag( + name: .customLong("attachable"), + help: "Accepted for compose-spec parity. Currently a no-op on apple/container, which has no swarm concept.") + var attachable: Bool = false + + @Flag( + name: .customLong("ipv6"), + help: "Enable IPv6 even when no --subnet-v6 is supplied. The runtime asks vmnet to auto-allocate an IPv6 prefix.") + var enableIPv6: Bool = false @OptionGroup public var logOptions: Flags.Logging @@ -66,11 +101,44 @@ extension Application { let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels)) let parsedOptions = Utility.parseKeyValuePairs(options) let mode: NetworkMode = hostOnly ? .hostOnly : .nat + + let parsedAuxAddresses: [String: IPv4Address]? + if auxAddresses.isEmpty { + parsedAuxAddresses = nil + } else { + let raw = Utility.parseKeyValuePairs(auxAddresses) + var mapped: [String: IPv4Address] = [:] + mapped.reserveCapacity(raw.count) + for (hostname, addressText) in raw { + mapped[hostname] = try IPv4Address(addressText) + } + parsedAuxAddresses = mapped + } + + let parsedDriverOpts: [String: String]? = driverOpts.isEmpty ? nil : Utility.parseKeyValuePairs(driverOpts) + + // Compose-spec parity: report acceptance of attachable but make it explicit + // that apple/container has no swarm-style attachment concept. + if attachable { + FileHandle.standardError.write( + Data("Note: --attachable is accepted for compose-spec parity but has no behavioral effect on apple/container.\n".utf8) + ) + } + + // Either an explicit --subnet-v6 or an explicit --ipv6 enables IPv6 on the network. + let resolvedEnableIPv6: Bool? = (enableIPv6 || ipv6Subnet != nil) ? true : nil + let config = try NetworkConfiguration( name: self.name, mode: mode, ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, + ipv4Gateway: ipv4Gateway, + ipv4Range: ipv4Range, + auxAddresses: parsedAuxAddresses, + driverOpts: parsedDriverOpts, + attachable: attachable ? true : nil, + enableIPv6: resolvedEnableIPv6, labels: parsedLabels, plugin: self.plugin, options: parsedOptions diff --git a/Sources/ContainerResource/Network/NetworkConfiguration.swift b/Sources/ContainerResource/Network/NetworkConfiguration.swift index 7678c4713..4e0c5e974 100644 --- a/Sources/ContainerResource/Network/NetworkConfiguration.swift +++ b/Sources/ContainerResource/Network/NetworkConfiguration.swift @@ -48,12 +48,52 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { /// Plugin-specific options for this network. public let options: [String: String] + /// The IPv4 gateway address for the network, if explicitly specified. + /// When `nil`, the runtime derives the gateway from `ipv4Subnet` (typically + /// the first usable host address). When set, the value must lie within + /// `ipv4Subnet`. + public let ipv4Gateway: IPv4Address? + + /// A sub-CIDR of `ipv4Subnet` from which the runtime should allocate + /// dynamic IPv4 addresses. When `nil`, the entire usable subnet is + /// available. When set, must be contained within `ipv4Subnet`. + public let ipv4Range: CIDRv4? + + /// Static hostname-to-IPv4 reservations that must not be handed out by the + /// dynamic allocator. Each address must lie within `ipv4Subnet`. Entries + /// outside `ipv4Range` (when specified) are recorded but have no allocator + /// effect because they are already outside the dynamic pool. + public let auxAddresses: [String: IPv4Address]? + + /// Free-form network driver options. Persisted on the configuration and + /// forwarded to the network plugin via repeated `--driver-opt KEY=VALUE` + /// arguments. The vmnet plugin accepts no options today; future driver + /// enhancements may interpret known keys without changing the wire format. + public let driverOpts: [String: String]? + + /// Whether to allow ad-hoc container attachments to the network. Accepted + /// for compose-spec parity but currently a no-op on apple/container, + /// which does not have a multi-host swarm concept. + public let attachable: Bool? + + /// Request IPv6 connectivity even when no explicit `ipv6Subnet` is + /// configured. When `true` and `ipv6Subnet` is `nil`, the runtime asks + /// vmnet to auto-allocate an IPv6 prefix at network start. The flag is + /// implicitly `true` whenever `ipv6Subnet` is set. + public let enableIPv6: Bool? + /// Creates a network configuration public init( name: String, mode: NetworkMode, ipv4Subnet: CIDRv4? = nil, ipv6Subnet: CIDRv6? = nil, + ipv4Gateway: IPv4Address? = nil, + ipv4Range: CIDRv4? = nil, + auxAddresses: [String: IPv4Address]? = nil, + driverOpts: [String: String]? = nil, + attachable: Bool? = nil, + enableIPv6: Bool? = nil, labels: ResourceLabels = .init(), plugin: String, options: [String: String] = [:] @@ -63,6 +103,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { self.mode = mode self.ipv4Subnet = ipv4Subnet self.ipv6Subnet = ipv6Subnet + self.ipv4Gateway = ipv4Gateway + self.ipv4Range = ipv4Range + self.auxAddresses = auxAddresses + self.driverOpts = driverOpts + self.attachable = attachable + self.enableIPv6 = enableIPv6 self.labels = labels self.plugin = plugin self.options = options @@ -78,6 +124,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { case mode case ipv4Subnet case ipv6Subnet + case ipv4Gateway + case ipv4Range + case auxAddresses + case driverOpts + case attachable + case enableIPv6 case labels case plugin case options @@ -102,6 +154,23 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { ipv4Subnet = try subnetText.map { try CIDRv4($0) } ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) .map { try CIDRv6($0) } + ipv4Gateway = try container.decodeIfPresent(String.self, forKey: .ipv4Gateway) + .map { try IPv4Address($0) } + ipv4Range = try container.decodeIfPresent(String.self, forKey: .ipv4Range) + .map { try CIDRv4($0) } + if let rawAux = try container.decodeIfPresent([String: String].self, forKey: .auxAddresses) { + var decoded: [String: IPv4Address] = [:] + decoded.reserveCapacity(rawAux.count) + for (hostname, addressText) in rawAux { + decoded[hostname] = try IPv4Address(addressText) + } + auxAddresses = decoded + } else { + auxAddresses = nil + } + driverOpts = try container.decodeIfPresent([String: String].self, forKey: .driverOpts) + attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable) + enableIPv6 = try container.decodeIfPresent(Bool.self, forKey: .enableIPv6) let decodedLabels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] labels = try .init(decodedLabels) @@ -132,6 +201,15 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { try container.encode(mode, forKey: .mode) try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet) try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) + try container.encodeIfPresent(ipv4Gateway?.description, forKey: .ipv4Gateway) + try container.encodeIfPresent(ipv4Range, forKey: .ipv4Range) + if let auxAddresses { + let encodable = auxAddresses.mapValues { $0.description } + try container.encode(encodable, forKey: .auxAddresses) + } + try container.encodeIfPresent(driverOpts, forKey: .driverOpts) + try container.encodeIfPresent(attachable, forKey: .attachable) + try container.encodeIfPresent(enableIPv6, forKey: .enableIPv6) try container.encode(labels, forKey: .labels) try container.encode(plugin, forKey: .plugin) try container.encode(options, forKey: .options) @@ -141,6 +219,32 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable { guard NetworkResource.nameValid(name) else { throw ContainerizationError(.invalidArgument, message: "invalid network name: \(name)") } + if let ipv4Gateway, let ipv4Subnet { + guard ipv4Subnet.contains(ipv4Gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(ipv4Gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } + } + if let ipv4Range, let ipv4Subnet { + guard ipv4Subnet.contains(ipv4Range.lower) && ipv4Subnet.contains(ipv4Range.upper) else { + throw ContainerizationError( + .invalidArgument, + message: "ip-range \(ipv4Range) is not contained within IPv4 subnet \(ipv4Subnet)" + ) + } + } + if let auxAddresses, let ipv4Subnet { + for (hostname, address) in auxAddresses { + guard ipv4Subnet.contains(address) else { + throw ContainerizationError( + .invalidArgument, + message: "aux-address \(hostname)=\(address) is not within IPv4 subnet \(ipv4Subnet)" + ) + } + } + } } } diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift index 7d67f1f32..f194780fc 100644 --- a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -67,6 +67,21 @@ extension NetworkVmnetHelper { return .reserved }() + @Option(name: .customLong("gateway"), help: "Explicit IPv4 gateway address (optional; default derived from subnet)") + var ipv4Gateway: String? + + @Option(name: .customLong("ip-range"), help: "Sub-CIDR of subnet from which dynamic IPv4 addresses are allocated") + var ipv4Range: String? + + @Option(name: .customLong("aux-addresses"), help: "JSON-encoded hostname-to-IPv4 reservations") + var auxAddressesJSON: String? + + @Option(name: .customLong("driver-opt"), help: "Free-form driver option (KEY=VALUE), repeatable") + var driverOpts: [String] = [] + + @Flag(name: .customLong("ipv6"), help: "Enable IPv6 even when no IPv6 subnet is supplied") + var enableIPv6 = false + var logRoot = LogRoot.path func run() async throws { @@ -82,12 +97,37 @@ extension NetworkVmnetHelper { log.info("configuring XPC server") let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) } let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) } + let ipv4Gateway = try self.ipv4Gateway.map { try IPv4Address($0) } + let ipv4Range = try self.ipv4Range.map { try CIDRv4($0) } + let auxAddresses = try Self.decodeAuxAddresses(self.auxAddressesJSON) + let parsedDriverOpts: [String: String]? + if driverOpts.isEmpty { + parsedDriverOpts = nil + } else { + var collected: [String: String] = [:] + collected.reserveCapacity(driverOpts.count) + for entry in driverOpts { + guard let separatorIndex = entry.firstIndex(of: "=") else { + throw ContainerizationError(.invalidArgument, message: "driver option '\(entry)' is missing '='") + } + let key = String(entry[.. [String: IPv4Address]? { + guard let jsonText, !jsonText.isEmpty else { return nil } + guard let data = jsonText.data(using: .utf8) else { + throw ContainerizationError(.invalidArgument, message: "aux-addresses payload is not valid UTF-8") + } + let raw = try JSONDecoder().decode([String: String].self, from: data) + var decoded: [String: IPv4Address] = [:] + decoded.reserveCapacity(raw.count) + for (hostname, addressText) in raw { + decoded[hostname] = try IPv4Address(addressText) + } + return decoded + } } } diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 7fe35fae8..de298a93a 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -174,6 +174,12 @@ public actor NetworksService { mode: configuration.mode, ipv4Subnet: configuration.ipv4Subnet, ipv6Subnet: configuration.ipv6Subnet, + ipv4Gateway: configuration.ipv4Gateway, + ipv4Range: configuration.ipv4Range, + auxAddresses: configuration.auxAddresses, + driverOpts: configuration.driverOpts, + attachable: configuration.attachable, + enableIPv6: configuration.enableIPv6, labels: configuration.labels, plugin: configuration.plugin, options: configuration.options @@ -395,6 +401,34 @@ public actor NetworksService { args += ["--variant", variant] } + if let ipv4Gateway = configuration.ipv4Gateway { + args += ["--gateway", ipv4Gateway.description] + } + + if let ipv4Range = configuration.ipv4Range { + args += ["--ip-range", ipv4Range.description] + } + + if let auxAddresses = configuration.auxAddresses, !auxAddresses.isEmpty { + // Encode as JSON so a single argv value can carry the full hostname-to-IP mapping. + let serializable = auxAddresses.mapValues { $0.description } + let payload = try JSONEncoder().encode(serializable) + guard let payloadString = String(data: payload, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode aux-addresses for plugin") + } + args += ["--aux-addresses", payloadString] + } + + if let driverOpts = configuration.driverOpts { + for (key, value) in driverOpts { + args += ["--driver-opt", "\(key)=\(value)"] + } + } + + if configuration.enableIPv6 == true { + args.append("--ipv6") + } + let entityPath = try store.entityPath(configuration.id) try pluginLoader.registerWithLaunchd( plugin: networkPlugin, diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift index b7d3aeebb..57ba7564f 100644 --- a/Sources/Services/Network/Server/AttachmentAllocator.swift +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -56,4 +56,12 @@ actor AttachmentAllocator { func lookup(hostname: String) async throws -> UInt32? { hostnames[hostname] } + + /// Pre-reserve a hostname-to-address mapping in the allocator's pool. + /// The address must be within the allocator's range; out-of-range or + /// already-allocated addresses cause the underlying allocator to throw. + func reserveHostname(hostname: String, address: UInt32) async throws { + try allocator.reserve(address) + hostnames[hostname] = address + } } diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 70d17d396..bfdaf2981 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -30,6 +30,7 @@ public actor DefaultNetworkService: NetworkService { /// Set up a network service for the specified network. public init( network: any Network, + configuration: NetworkConfiguration, log: Logger ) async throws { guard let status = await network.status else { @@ -37,12 +38,87 @@ public actor DefaultNetworkService: NetworkService { } let subnet = status.ipv4Subnet - let size = Int(subnet.upper.value - subnet.lower.value - 3) + + // Determine the allocator range. By default skip the network address, + // gateway, and broadcast (`subnet.lower + 2 ... subnet.upper - 1`). When + // an explicit `ipv4Range` was configured, use exactly that span as the + // allocator's pool — the user has taken responsibility for excluding + // the gateway and any aux reservations. + let allocatorLower: UInt32 + let allocatorSize: Int + if let ipv4Range = configuration.ipv4Range { + allocatorLower = ipv4Range.lower.value + allocatorSize = Int(ipv4Range.upper.value - ipv4Range.lower.value + 1) + } else { + allocatorLower = subnet.lower.value + 2 + allocatorSize = Int(subnet.upper.value - subnet.lower.value - 3) + } self.network = network self.log = log - self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.allocator = try AttachmentAllocator(lower: allocatorLower, size: allocatorSize) self.macAddresses = [:] self.allocationsBySession = [:] + + // If the configured IPv4 gateway differs from the default (`subnet.lower + 1`) + // and falls within the dynamic allocator's pool, reserve it so the runtime + // never hands the gateway out to a container. + if let configuredGateway = configuration.ipv4Gateway, configuredGateway.value != subnet.lower.value + 1 { + if configuredGateway.value >= allocatorLower + && configuredGateway.value < allocatorLower + UInt32(allocatorSize) + { + do { + try await self.allocator.reserveHostname( + hostname: "__gateway__", + address: configuredGateway.value + ) + } catch { + log.warning( + "failed to pre-reserve configured gateway address", + metadata: [ + "address": "\(configuredGateway)", + "error": "\(error)", + ]) + } + } + } + + // Pre-reserve aux addresses that fall within the allocator's range so the + // dynamic allocator never hands them out. Out-of-range aux addresses are + // recorded in logs only — they are already outside the dynamic pool. + if let auxAddresses = configuration.auxAddresses { + for (hostname, address) in auxAddresses { + let value = address.value + guard + value >= allocatorLower + && value < allocatorLower + UInt32(allocatorSize) + else { + log.info( + "aux address outside dynamic allocation range; recorded for reference only", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + continue + } + do { + try await self.allocator.reserveHostname(hostname: hostname, address: value) + log.info( + "pre-reserved aux address", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + } catch { + log.warning( + "failed to pre-reserve aux address", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + "error": "\(error)", + ]) + } + } + } } @Sendable diff --git a/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift index 131e8af28..1e3d9c209 100644 --- a/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift +++ b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift @@ -72,7 +72,13 @@ public actor AllocationOnlyVmnetNetwork: Network { ) let ipv4Subnet = configuration.ipv4Subnet ?? Self.defaultIPv4Subnet - let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + let gateway = configuration.ipv4Gateway ?? IPv4Address(ipv4Subnet.lower.value + 1) + guard ipv4Subnet.contains(gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } self._status = NetworkStatus( ipv4Subnet: ipv4Subnet, ipv4Gateway: gateway, diff --git a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift index 5b0fee6ad..23440b560 100644 --- a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift +++ b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift @@ -123,7 +123,13 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { // set the IPv4 subnet if let ipv4Subnet { - let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + let gateway = configuration.ipv4Gateway ?? IPv4Address(ipv4Subnet.lower.value + 1) + guard ipv4Subnet.contains(gateway) else { + throw ContainerizationError( + .invalidArgument, + message: "gateway \(gateway) is not within IPv4 subnet \(ipv4Subnet)" + ) + } var gatewayAddr = in_addr() inet_pton(AF_INET, gateway.description, &gatewayAddr) let mask = IPv4Address(ipv4Subnet.prefix.prefixMask32) @@ -168,7 +174,7 @@ public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { let lower = IPv4Address(subnetValue & maskValue) let upper = IPv4Address(lower.value + ~maskValue) let runningSubnet = try CIDRv4(lower: lower, upper: upper) - let runningGateway = IPv4Address(runningSubnet.lower.value + 1) + let runningGateway = configuration.ipv4Gateway ?? IPv4Address(runningSubnet.lower.value + 1) var prefixAddr = in6_addr() var prefixLength = UInt8(0) diff --git a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift index e3fd1faa2..19710cd3c 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 @@ -82,4 +83,151 @@ struct NetworkConfigurationTest { } } + @Test func testGatewayWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let gateway = try IPv4Address("10.0.0.254") + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Gateway: gateway, + plugin: "container-network-vmnet" + ) + } + + @Test func testGatewayOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let gateway = try IPv4Address("10.0.1.1") + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Gateway: gateway, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("is not within IPv4 subnet")) + return true + } + } + + @Test func testIPRangeWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let ipv4Range = try CIDRv4("10.0.0.128/28") + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Range: ipv4Range, + plugin: "container-network-vmnet" + ) + } + + @Test func testIPRangeOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let ipv4Range = try CIDRv4("10.0.1.0/28") + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + ipv4Range: ipv4Range, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("ip-range")) + return true + } + } + + @Test func testAuxAddressesWithinSubnet() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let aux: [String: IPv4Address] = [ + "db": try IPv4Address("10.0.0.10"), + "web": try IPv4Address("10.0.0.20"), + ] + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + auxAddresses: aux, + plugin: "container-network-vmnet" + ) + } + + @Test func testAuxAddressOutsideSubnetRejected() throws { + let ipv4Subnet = try CIDRv4("10.0.0.0/24") + let aux: [String: IPv4Address] = [ + "oops": try IPv4Address("172.16.0.1") + ] + #expect { + _ = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: ipv4Subnet, + auxAddresses: aux, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.contains("aux-address")) + return true + } + } + + @Test func testNewFieldsRoundTripThroughCodable() throws { + let aux: [String: IPv4Address] = ["db": try IPv4Address("10.0.0.10")] + let original = try NetworkConfiguration( + name: "net", + mode: .nat, + ipv4Subnet: try CIDRv4("10.0.0.0/24"), + ipv4Gateway: try IPv4Address("10.0.0.254"), + ipv4Range: try CIDRv4("10.0.0.128/28"), + auxAddresses: aux, + driverOpts: ["key": "value"], + attachable: true, + enableIPv6: true, + plugin: "container-network-vmnet" + ) + + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(NetworkConfiguration.self, from: data) + + #expect(decoded.ipv4Gateway?.description == "10.0.0.254") + #expect(decoded.ipv4Range?.description == original.ipv4Range?.description) + #expect(decoded.auxAddresses?["db"]?.description == "10.0.0.10") + #expect(decoded.driverOpts == ["key": "value"]) + #expect(decoded.attachable == true) + #expect(decoded.enableIPv6 == true) + } + + @Test func testLegacyConfigurationDecodesWithoutNewFields() throws { + // Pre-existing on-disk configurations were persisted without the new + // optional fields. Verify they decode cleanly with `nil` defaults. + let legacyJSON = """ + { + \"id\": \"legacy\", + \"mode\": \"nat\", + \"creationDate\": 0, + \"labels\": {}, + \"pluginInfo\": {\"plugin\": \"container-network-vmnet\"} + } + """ + let data = legacyJSON.data(using: .utf8)! + let decoded = try JSONDecoder().decode(NetworkConfiguration.self, from: data) + + #expect(decoded.ipv4Gateway == nil) + #expect(decoded.ipv4Range == nil) + #expect(decoded.auxAddresses == nil) + #expect(decoded.driverOpts == nil) + #expect(decoded.attachable == nil) + #expect(decoded.enableIPv6 == nil) + } + }