Skip to content
Draft
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
68 changes: 68 additions & 0 deletions Sources/ContainerCommands/Network/NetworkCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
104 changes: 104 additions & 0 deletions Sources/ContainerResource/Network/NetworkConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand All @@ -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)"
)
}
}
}
}
}

Expand Down
60 changes: 59 additions & 1 deletion Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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[..<separatorIndex])
let value = String(entry[entry.index(after: separatorIndex)...])
collected[key] = value
}
parsedDriverOpts = collected
}

let configuration = try NetworkConfiguration(
name: id,
mode: mode,
ipv4Subnet: ipv4Subnet,
ipv6Subnet: ipv6Subnet,
ipv4Gateway: ipv4Gateway,
ipv4Range: ipv4Range,
auxAddresses: auxAddresses,
driverOpts: parsedDriverOpts,
attachable: nil,
enableIPv6: (self.enableIPv6 || ipv6Subnet != nil) ? true : nil,
plugin: NetworkVmnetHelper._commandName,
options: ["variant": self.variant.rawValue]
)
Expand All @@ -97,7 +137,11 @@ extension NetworkVmnetHelper {
log: log
)
try await network.start()
let service = try await DefaultNetworkService(network: network, log: log)
let service = try await DefaultNetworkService(
network: network,
configuration: configuration,
log: log
)
let harness = NetworkHarness(service: service)
let xpc = XPCServer(
identifier: serviceIdentifier,
Expand Down Expand Up @@ -136,5 +180,19 @@ extension NetworkVmnetHelper {
return try ReservedVmnetNetwork(configuration: configuration, log: log)
}
}

private static func decodeAuxAddresses(_ jsonText: String?) throws -> [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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions Sources/Services/Network/Server/AttachmentAllocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Loading