Skip to content

Commit 237a1b4

Browse files
committed
feat(network): apply richer IPAM at network creation time
Extends `container network create` and the `container-network-vmnet` plugin to honor six compose-spec / Docker-style network creation options that were previously silently dropped: `--gateway`, `--ip-range`, `--aux-address`, `--driver-opt`, `--ipv6`, and `--attachable`. The vmnet plugin now actually wires the user's gateway override and IPv4 sub-range constraints through to the runtime; aux-address reservations are pre-allocated in the per-network attachment allocator so the dynamic pool never hands them out. Motivation ---------- Container-Compose (and other compose-spec orchestrators) decode these fields from `docker-compose.yml` but cannot apply them today because `container network create` only accepts `--label`, `--internal`, `--subnet`, `--subnet-v6`, `--plugin`, and `--plugin-variant`. The gap was tracked downstream in CHAOS-1334 and surfaced as warn-and-skip behaviour in container-compose's `setupNetwork` path. With this PR the runtime accepts the full IPAM surface and the plugin honours each field at network start. What this PR changes -------------------- - Sources/ContainerResource/Network/NetworkConfiguration.swift: six new optional fields on the persisted `NetworkConfiguration` (`ipv4Gateway`, `ipv4Range`, `auxAddresses`, `driverOpts`, `attachable`, `enableIPv6`). All decoded with `decodeIfPresent`, encoded with `encodeIfPresent`, and validated end-to-end (gateway, range, and aux-addresses must lie inside `ipv4Subnet` when both are configured). - Sources/ContainerCommands/Network/NetworkCreate.swift: matching CLI flags (`--gateway`, `--ip-range`, `--aux-address HOSTNAME=IP` repeatable, `--driver-opt KEY=VALUE` repeatable, `--ipv6`, and `--attachable`). The CLI emits an explicit advisory on stderr when `--attachable` is requested because apple/container has no swarm attachment concept. - Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift: `registerService` now serializes the new configuration fields into argv passed to the plugin's `start` subcommand. `auxAddresses` is encoded as a single JSON argv value to keep the wire shape simple and trivially extensible; `driverOpts` is forwarded as repeated `--driver-opt KEY=VALUE` entries. - Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift: parses the new argv (`--gateway`, `--ip-range`, `--aux-addresses`, `--driver-opt`, `--ipv6`) and reconstructs a richer `NetworkConfiguration` for the vmnet variants. - Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift and AllocationOnlyVmnetNetwork.swift: the IPv4 gateway is no longer hard-coded to `subnet.lower + 1`. When the configuration carries an explicit `ipv4Gateway`, that address is passed directly to `vmnet_network_configuration_set_ipv4_subnet` and validated as in-subnet. The allocation-only variant explicitly rejects the IPv6 request paths with an actionable error pointing users at the `reserved` variant on macOS 26+. - Sources/Services/ContainerNetworkService/Server/NetworkService.swift: the per-network attachment allocator now accepts an `ipv4Range` override (so the dynamic pool spans the user-supplied sub-CIDR rather than the full subnet) and pre-reserves both a custom gateway and any in-range `auxAddresses` through `RotatingAddressAllocator`'s existing `reserve(_:)` API. Out-of-range aux entries are recorded for reference but require no allocator state because they are already outside the dynamic pool. - Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift: small `reserveHostname(hostname:address:)` helper that wraps the underlying allocator's `reserve(_:)` with the actor's hostname-to-index dictionary, so reserved entries round-trip through `lookup` / `deallocate`. Wire compatibility ------------------ All new `NetworkConfiguration` fields are optional and decoded with `decodeIfPresent`; configurations persisted by older daemons decode cleanly with `nil` defaults (covered by the `testLegacyConfigurationDecodesWithoutNewFields` regression test). Plugin argv is purely additive: a newer `NetworksService` against an older plugin will pass argv that the plugin ignores; an older `NetworksService` against a newer plugin simply omits the new flags and the plugin behaves as before. Known limitations (intentional, follow-up work) ----------------------------------------------- - `--attachable` is accepted at the CLI for compose-spec parity but is not threaded into the plugin and produces no behavioural change on apple/container, which has no swarm-mode attachment concept. The CLI surfaces this explicitly on stderr. - `--driver-opt KEY=VALUE` is parsed, persisted on the configuration, and forwarded through to the plugin process, but the vmnet plugin does not currently interpret any specific keys. Keeping the surface on day one means future plugin enhancements can interpret options (e.g. DHCP toggles) without another wire-format break. - Bare `--ipv6` (no `--subnet-v6`) takes effect on the `reserved` variant only; the `allocation-only` variant does not yet support IPv6 and now emits an actionable error pointing at the `reserved` variant on macOS 26+. - The IPv4 gateway override only takes effect when an explicit `--subnet` is also supplied; without a subnet, vmnet is the source of truth for both subnet and gateway. Verification ------------ - `swift build -c release` clean on macOS 26 / Apple silicon. - `swift test --filter ContainerResourceTests.NetworkConfigurationTest` passes 11 tests (3 pre-existing + 8 new), covering: gateway/range/ aux-address validation (positive and negative), full-shape Codable round-trip, and decoding a legacy (pre-PR) JSON configuration with none of the new fields populated.
1 parent 2ee3f3d commit 237a1b4

9 files changed

Lines changed: 502 additions & 5 deletions

File tree

Sources/ContainerCommands/Network/NetworkCreate.swift

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,42 @@ extension Application {
5454
@Option(name: .long, help: "Set the variant of the network plugin to use.")
5555
var pluginVariant: String?
5656

57+
@Option(
58+
name: .customLong("gateway"),
59+
help: "Set the IPv4 gateway address for the network. Defaults to the first usable host address in --subnet.",
60+
transform: {
61+
try IPv4Address($0)
62+
})
63+
var ipv4Gateway: IPv4Address? = nil
64+
65+
@Option(
66+
name: .customLong("ip-range"),
67+
help: "Restrict dynamic IPv4 allocation to a sub-range of --subnet, expressed as CIDR.",
68+
transform: {
69+
try CIDRv4($0)
70+
})
71+
var ipv4Range: CIDRv4? = nil
72+
73+
@Option(
74+
name: .customLong("aux-address"),
75+
help: "Reserve a static hostname-to-IPv4 mapping (HOSTNAME=IPV4). Repeatable.")
76+
var auxAddresses: [String] = []
77+
78+
@Option(
79+
name: .customLong("driver-opt"),
80+
help: "Set a free-form network driver option (KEY=VALUE). Repeatable. Currently informational; the vmnet plugin does not interpret any keys.")
81+
var driverOpts: [String] = []
82+
83+
@Flag(
84+
name: .customLong("attachable"),
85+
help: "Accepted for compose-spec parity. Currently a no-op on apple/container, which has no swarm concept.")
86+
var attachable: Bool = false
87+
88+
@Flag(
89+
name: .customLong("ipv6"),
90+
help: "Enable IPv6 even when no --subnet-v6 is supplied. The runtime asks vmnet to auto-allocate an IPv6 prefix.")
91+
var enableIPv6: Bool = false
92+
5793
@OptionGroup
5894
public var logOptions: Flags.Logging
5995

@@ -65,11 +101,44 @@ extension Application {
65101
public func run() async throws {
66102
let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels))
67103
let mode: NetworkMode = hostOnly ? .hostOnly : .nat
104+
105+
let parsedAuxAddresses: [String: IPv4Address]?
106+
if auxAddresses.isEmpty {
107+
parsedAuxAddresses = nil
108+
} else {
109+
let raw = Utility.parseKeyValuePairs(auxAddresses)
110+
var mapped: [String: IPv4Address] = [:]
111+
mapped.reserveCapacity(raw.count)
112+
for (hostname, addressText) in raw {
113+
mapped[hostname] = try IPv4Address(addressText)
114+
}
115+
parsedAuxAddresses = mapped
116+
}
117+
118+
let parsedDriverOpts: [String: String]? = driverOpts.isEmpty ? nil : Utility.parseKeyValuePairs(driverOpts)
119+
120+
// Compose-spec parity: report acceptance of attachable but make it explicit
121+
// that apple/container has no swarm-style attachment concept.
122+
if attachable {
123+
FileHandle.standardError.write(
124+
Data("Note: --attachable is accepted for compose-spec parity but has no behavioral effect on apple/container.\n".utf8)
125+
)
126+
}
127+
128+
// Either an explicit --subnet-v6 or an explicit --ipv6 enables IPv6 on the network.
129+
let resolvedEnableIPv6: Bool? = (enableIPv6 || ipv6Subnet != nil) ? true : nil
130+
68131
let config = try NetworkConfiguration(
69132
id: self.name,
70133
mode: mode,
71134
ipv4Subnet: ipv4Subnet,
72135
ipv6Subnet: ipv6Subnet,
136+
ipv4Gateway: ipv4Gateway,
137+
ipv4Range: ipv4Range,
138+
auxAddresses: parsedAuxAddresses,
139+
driverOpts: parsedDriverOpts,
140+
attachable: attachable ? true : nil,
141+
enableIPv6: resolvedEnableIPv6,
73142
labels: parsedLabels,
74143
pluginInfo: NetworkPluginInfo(plugin: self.plugin, variant: self.pluginVariant)
75144
)

Sources/ContainerResource/Network/NetworkConfiguration.swift

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,52 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
5454
/// to be proliferated to most users when they update container.
5555
public let pluginInfo: NetworkPluginInfo?
5656

57+
/// The IPv4 gateway address for the network, if explicitly specified.
58+
/// When `nil`, the runtime derives the gateway from `ipv4Subnet` (typically
59+
/// the first usable host address). When set, the value must lie within
60+
/// `ipv4Subnet`.
61+
public let ipv4Gateway: IPv4Address?
62+
63+
/// A sub-CIDR of `ipv4Subnet` from which the runtime should allocate
64+
/// dynamic IPv4 addresses. When `nil`, the entire usable subnet is
65+
/// available. When set, must be contained within `ipv4Subnet`.
66+
public let ipv4Range: CIDRv4?
67+
68+
/// Static hostname-to-IPv4 reservations that must not be handed out by the
69+
/// dynamic allocator. Each address must lie within `ipv4Subnet`. Entries
70+
/// outside `ipv4Range` (when specified) are recorded but have no allocator
71+
/// effect because they are already outside the dynamic pool.
72+
public let auxAddresses: [String: IPv4Address]?
73+
74+
/// Free-form network driver options. Persisted on the configuration and
75+
/// forwarded to the network plugin via repeated `--driver-opt KEY=VALUE`
76+
/// arguments. The vmnet plugin accepts no options today; future driver
77+
/// enhancements may interpret known keys without changing the wire format.
78+
public let driverOpts: [String: String]?
79+
80+
/// Whether to allow ad-hoc container attachments to the network. Accepted
81+
/// for compose-spec parity but currently a no-op on apple/container,
82+
/// which does not have a multi-host swarm concept.
83+
public let attachable: Bool?
84+
85+
/// Request IPv6 connectivity even when no explicit `ipv6Subnet` is
86+
/// configured. When `true` and `ipv6Subnet` is `nil`, the runtime asks
87+
/// vmnet to auto-allocate an IPv6 prefix at network start. The flag is
88+
/// implicitly `true` whenever `ipv6Subnet` is set.
89+
public let enableIPv6: Bool?
90+
5791
/// Creates a network configuration
5892
public init(
5993
id: String,
6094
mode: NetworkMode,
6195
ipv4Subnet: CIDRv4? = nil,
6296
ipv6Subnet: CIDRv6? = nil,
97+
ipv4Gateway: IPv4Address? = nil,
98+
ipv4Range: CIDRv4? = nil,
99+
auxAddresses: [String: IPv4Address]? = nil,
100+
driverOpts: [String: String]? = nil,
101+
attachable: Bool? = nil,
102+
enableIPv6: Bool? = nil,
63103
labels: ResourceLabels = .init(),
64104
pluginInfo: NetworkPluginInfo?
65105
) throws {
@@ -68,6 +108,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
68108
self.mode = mode
69109
self.ipv4Subnet = ipv4Subnet
70110
self.ipv6Subnet = ipv6Subnet
111+
self.ipv4Gateway = ipv4Gateway
112+
self.ipv4Range = ipv4Range
113+
self.auxAddresses = auxAddresses
114+
self.driverOpts = driverOpts
115+
self.attachable = attachable
116+
self.enableIPv6 = enableIPv6
71117
self.labels = labels
72118
self.pluginInfo = pluginInfo
73119
try validate()
@@ -79,6 +125,12 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
79125
case mode
80126
case ipv4Subnet
81127
case ipv6Subnet
128+
case ipv4Gateway
129+
case ipv4Range
130+
case auxAddresses
131+
case driverOpts
132+
case attachable
133+
case enableIPv6
82134
case labels
83135
case pluginInfo
84136
// TODO: retain for deserialization compatibility for now, remove later
@@ -99,6 +151,23 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
99151
ipv4Subnet = try subnetText.map { try CIDRv4($0) }
100152
ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet)
101153
.map { try CIDRv6($0) }
154+
ipv4Gateway = try container.decodeIfPresent(String.self, forKey: .ipv4Gateway)
155+
.map { try IPv4Address($0) }
156+
ipv4Range = try container.decodeIfPresent(String.self, forKey: .ipv4Range)
157+
.map { try CIDRv4($0) }
158+
if let rawAux = try container.decodeIfPresent([String: String].self, forKey: .auxAddresses) {
159+
var decoded: [String: IPv4Address] = [:]
160+
decoded.reserveCapacity(rawAux.count)
161+
for (hostname, addressText) in rawAux {
162+
decoded[hostname] = try IPv4Address(addressText)
163+
}
164+
auxAddresses = decoded
165+
} else {
166+
auxAddresses = nil
167+
}
168+
driverOpts = try container.decodeIfPresent([String: String].self, forKey: .driverOpts)
169+
attachable = try container.decodeIfPresent(Bool.self, forKey: .attachable)
170+
enableIPv6 = try container.decodeIfPresent(Bool.self, forKey: .enableIPv6)
102171
let decodedLabels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
103172
labels = try .init(decodedLabels)
104173
pluginInfo = try container.decodeIfPresent(NetworkPluginInfo.self, forKey: .pluginInfo)
@@ -114,6 +183,15 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
114183
try container.encode(mode, forKey: .mode)
115184
try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet)
116185
try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet)
186+
try container.encodeIfPresent(ipv4Gateway?.description, forKey: .ipv4Gateway)
187+
try container.encodeIfPresent(ipv4Range, forKey: .ipv4Range)
188+
if let auxAddresses {
189+
let encodable = auxAddresses.mapValues { $0.description }
190+
try container.encode(encodable, forKey: .auxAddresses)
191+
}
192+
try container.encodeIfPresent(driverOpts, forKey: .driverOpts)
193+
try container.encodeIfPresent(attachable, forKey: .attachable)
194+
try container.encodeIfPresent(enableIPv6, forKey: .enableIPv6)
117195
try container.encode(labels, forKey: .labels)
118196
try container.encodeIfPresent(pluginInfo, forKey: .pluginInfo)
119197
}
@@ -122,5 +200,31 @@ public struct NetworkConfiguration: Codable, Sendable, Identifiable {
122200
guard NetworkResource.nameValid(id) else {
123201
throw ContainerizationError(.invalidArgument, message: "invalid network ID: \(id)")
124202
}
203+
if let ipv4Gateway, let ipv4Subnet {
204+
guard ipv4Subnet.contains(ipv4Gateway) else {
205+
throw ContainerizationError(
206+
.invalidArgument,
207+
message: "gateway \(ipv4Gateway) is not within IPv4 subnet \(ipv4Subnet)"
208+
)
209+
}
210+
}
211+
if let ipv4Range, let ipv4Subnet {
212+
guard ipv4Subnet.contains(ipv4Range.lower) && ipv4Subnet.contains(ipv4Range.upper) else {
213+
throw ContainerizationError(
214+
.invalidArgument,
215+
message: "ip-range \(ipv4Range) is not contained within IPv4 subnet \(ipv4Subnet)"
216+
)
217+
}
218+
}
219+
if let auxAddresses, let ipv4Subnet {
220+
for (hostname, address) in auxAddresses {
221+
guard ipv4Subnet.contains(address) else {
222+
throw ContainerizationError(
223+
.invalidArgument,
224+
message: "aux-address \(hostname)=\(address) is not within IPv4 subnet \(ipv4Subnet)"
225+
)
226+
}
227+
}
228+
}
125229
}
126230
}

Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,21 @@ extension NetworkVmnetHelper {
6666
return .reserved
6767
}()
6868

69+
@Option(name: .customLong("gateway"), help: "Explicit IPv4 gateway address (optional; default derived from subnet)")
70+
var ipv4Gateway: String?
71+
72+
@Option(name: .customLong("ip-range"), help: "Sub-CIDR of subnet from which dynamic IPv4 addresses are allocated")
73+
var ipv4Range: String?
74+
75+
@Option(name: .customLong("aux-addresses"), help: "JSON-encoded hostname-to-IPv4 reservations")
76+
var auxAddressesJSON: String?
77+
78+
@Option(name: .customLong("driver-opt"), help: "Free-form driver option (KEY=VALUE), repeatable")
79+
var driverOpts: [String] = []
80+
81+
@Flag(name: .customLong("ipv6"), help: "Enable IPv6 even when no IPv6 subnet is supplied")
82+
var enableIPv6 = false
83+
6984
var logRoot = LogRoot.path
7085

7186
func run() async throws {
@@ -81,6 +96,25 @@ extension NetworkVmnetHelper {
8196
log.info("configuring XPC server")
8297
let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) }
8398
let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) }
99+
let ipv4Gateway = try self.ipv4Gateway.map { try IPv4Address($0) }
100+
let ipv4Range = try self.ipv4Range.map { try CIDRv4($0) }
101+
let auxAddresses = try Self.decodeAuxAddresses(self.auxAddressesJSON)
102+
let parsedDriverOpts: [String: String]?
103+
if driverOpts.isEmpty {
104+
parsedDriverOpts = nil
105+
} else {
106+
var collected: [String: String] = [:]
107+
collected.reserveCapacity(driverOpts.count)
108+
for entry in driverOpts {
109+
guard let separatorIndex = entry.firstIndex(of: "=") else {
110+
throw ContainerizationError(.invalidArgument, message: "driver option '\(entry)' is missing '='")
111+
}
112+
let key = String(entry[..<separatorIndex])
113+
let value = String(entry[entry.index(after: separatorIndex)...])
114+
collected[key] = value
115+
}
116+
parsedDriverOpts = collected
117+
}
84118
let pluginInfo = NetworkPluginInfo(
85119
plugin: NetworkVmnetHelper._commandName,
86120
variant: self.variant.rawValue
@@ -91,6 +125,12 @@ extension NetworkVmnetHelper {
91125
mode: mode,
92126
ipv4Subnet: ipv4Subnet,
93127
ipv6Subnet: ipv6Subnet,
128+
ipv4Gateway: ipv4Gateway,
129+
ipv4Range: ipv4Range,
130+
auxAddresses: auxAddresses,
131+
driverOpts: parsedDriverOpts,
132+
attachable: nil,
133+
enableIPv6: (self.enableIPv6 || ipv6Subnet != nil) ? true : nil,
94134
pluginInfo: pluginInfo
95135
)
96136
let network = try Self.createNetwork(
@@ -139,5 +179,19 @@ extension NetworkVmnetHelper {
139179
return try ReservedVmnetNetwork(configuration: configuration, log: log)
140180
}
141181
}
182+
183+
private static func decodeAuxAddresses(_ jsonText: String?) throws -> [String: IPv4Address]? {
184+
guard let jsonText, !jsonText.isEmpty else { return nil }
185+
guard let data = jsonText.data(using: .utf8) else {
186+
throw ContainerizationError(.invalidArgument, message: "aux-addresses payload is not valid UTF-8")
187+
}
188+
let raw = try JSONDecoder().decode([String: String].self, from: data)
189+
var decoded: [String: IPv4Address] = [:]
190+
decoded.reserveCapacity(raw.count)
191+
for (hostname, addressText) in raw {
192+
decoded[hostname] = try IPv4Address(addressText)
193+
}
194+
return decoded
195+
}
142196
}
143197
}

Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,34 @@ public actor NetworksService {
485485
args += ["--variant", variant]
486486
}
487487

488+
if let ipv4Gateway = configuration.ipv4Gateway {
489+
args += ["--gateway", ipv4Gateway.description]
490+
}
491+
492+
if let ipv4Range = configuration.ipv4Range {
493+
args += ["--ip-range", ipv4Range.description]
494+
}
495+
496+
if let auxAddresses = configuration.auxAddresses, !auxAddresses.isEmpty {
497+
// Encode as JSON so a single argv value can carry the full hostname-to-IP mapping.
498+
let serializable = auxAddresses.mapValues { $0.description }
499+
let payload = try JSONEncoder().encode(serializable)
500+
guard let payloadString = String(data: payload, encoding: .utf8) else {
501+
throw ContainerizationError(.internalError, message: "failed to encode aux-addresses for plugin")
502+
}
503+
args += ["--aux-addresses", payloadString]
504+
}
505+
506+
if let driverOpts = configuration.driverOpts {
507+
for (key, value) in driverOpts {
508+
args += ["--driver-opt", "\(key)=\(value)"]
509+
}
510+
}
511+
512+
if configuration.enableIPv6 == true {
513+
args.append("--ipv6")
514+
}
515+
488516
let entityPath = try store.entityPath(configuration.id)
489517
try pluginLoader.registerWithLaunchd(
490518
plugin: networkPlugin,

Sources/Services/ContainerNetworkService/Server/AllocationOnlyVmnetNetwork.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ public actor AllocationOnlyVmnetNetwork: Network {
6969

7070
let ipv4Subnet = configuration.ipv4Subnet ?? Self.defaultIPv4Subnet
7171

72-
let gateway = IPv4Address(ipv4Subnet.lower.value + 1)
72+
let gateway = configuration.ipv4Gateway ?? IPv4Address(ipv4Subnet.lower.value + 1)
73+
guard ipv4Subnet.contains(gateway) else {
74+
throw ContainerizationError(
75+
.invalidArgument,
76+
message: "gateway \(gateway) is not within IPv4 subnet \(ipv4Subnet)"
77+
)
78+
}
7379
let status = NetworkPluginStatus(
7480
ipv4Subnet: ipv4Subnet,
7581
ipv4Gateway: gateway,

Sources/Services/ContainerNetworkService/Server/AttachmentAllocator.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,12 @@ actor AttachmentAllocator {
6161
func lookup(hostname: String) async throws -> UInt32? {
6262
hostnames[hostname]
6363
}
64+
65+
/// Pre-reserve a hostname-to-address mapping in the allocator's pool.
66+
/// The address must be within the allocator's range; out-of-range or
67+
/// already-allocated addresses cause the underlying allocator to throw.
68+
func reserveHostname(hostname: String, address: UInt32) async throws {
69+
try allocator.reserve(address)
70+
hostnames[hostname] = address
71+
}
6472
}

0 commit comments

Comments
 (0)