diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerClient/Core/ContainerConfiguration.swift index d4de4d43..975d3544 100644 --- a/Sources/ContainerClient/Core/ContainerConfiguration.swift +++ b/Sources/ContainerClient/Core/ContainerConfiguration.swift @@ -88,8 +88,8 @@ public struct ContainerConfiguration: Sendable, Codable { do { networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) } catch { - let networkIds = try container.decode([String].self, forKey: .networks) - networks = try Utility.getAttachmentConfigurations(containerId: id, networkIds: networkIds) + let networkArgs = try container.decode([Flags.NetworkArg].self, forKey: .networks) + networks = try Utility.getAttachmentConfigurations(containerId: id, networks: networkArgs) } } else { networks = [] diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 3a01a2a3..18601f96 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -19,6 +19,32 @@ import ContainerizationError import Foundation public struct Flags { + public struct NetworkArg: ExpressibleByArgument, Decodable { + var networkId: String + var ip: String? + var invalidArgs: [String] = [] + + public init?(argument: String) { + let networkParts = argument.split(separator: ":", maxSplits: 1) + self.networkId = String(networkParts[0]) + if networkParts.count == 2 { + let args = networkParts[1].split(separator: ",") + for arg in args { + let parts = arg.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { + self.invalidArgs.append(String(arg)) + continue + } + if parts[0] == "ip" { + self.ip = String(parts[1]) + } else { + self.invalidArgs.append(String(arg)) + } + } + } + } + } + public struct Global: ParsableArguments { public init() {} @@ -151,8 +177,8 @@ public struct Flags { @Option(name: .long, help: "Use the specified name as the container ID") public var name: String? - @Option(name: [.customLong("network")], help: "Attach the container to a network") - public var networks: [String] = [] + @Option(name: [.customLong("network")], help: "Attach the container to a network (format: network_id[:ip=<>])") + public var networks: [NetworkArg] = [] @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") public var dnsDisabled = false diff --git a/Sources/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift index 383021d0..6f7a6173 100644 --- a/Sources/ContainerClient/Utility.swift +++ b/Sources/ContainerClient/Utility.swift @@ -180,7 +180,7 @@ public struct Utility { config.virtualization = management.virtualization - config.networks = try getAttachmentConfigurations(containerId: config.id, networkIds: management.networks) + config.networks = try getAttachmentConfigurations(containerId: config.id, networks: management.networks) for attachmentConfiguration in config.networks { let network: NetworkState = try await ClientNetwork.get(id: attachmentConfiguration.network) guard case .running(_, _) = network else { @@ -217,7 +217,7 @@ public struct Utility { return (config, kernel) } - static func getAttachmentConfigurations(containerId: String, networkIds: [String]) throws -> [AttachmentConfiguration] { + static func getAttachmentConfigurations(containerId: String, networks: [Flags.NetworkArg]) throws -> [AttachmentConfiguration] { // make an FQDN for the first interface let fqdn: String? if !containerId.contains(".") { @@ -232,18 +232,22 @@ public struct Utility { fqdn = "\(containerId)." } - guard networkIds.isEmpty else { + guard networks.isEmpty else { // networks may only be specified for macOS 26+ guard #available(macOS 26, *) else { throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") } // attach the first network using the fqdn, and the rest using just the container ID - return networkIds.enumerated().map { item in + return try networks.enumerated().map { item in + guard item.element.invalidArgs.isEmpty else { + throw ContainerizationError( + .invalidArgument, message: "invalid network arguments \(item.element.networkId): \(item.element.invalidArgs.joined(separator: ", "))") + } guard item.offset == 0 else { - return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: containerId)) + return AttachmentConfiguration(network: item.element.networkId, options: AttachmentOptions(hostname: containerId, ip: item.element.ip)) } - return AttachmentConfiguration(network: item.element, options: AttachmentOptions(hostname: fqdn ?? containerId)) + return AttachmentConfiguration(network: item.element.networkId, options: AttachmentOptions(hostname: fqdn ?? containerId, ip: item.element.ip)) } } // if no networks specified, attach to the default network diff --git a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift index ede611b2..ad6476d5 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift @@ -29,12 +29,18 @@ actor AttachmentAllocator { } /// Allocate a network address for a host. - func allocate(hostname: String) async throws -> UInt32 { + func allocate(hostname: String, staticIndex: UInt32? = nil) async throws -> UInt32 { // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists if let index = hostnames[hostname] { return index } + if let staticIndex { + try allocator.reserve(staticIndex) + hostnames[hostname] = staticIndex + return staticIndex + } + let index = try allocator.allocate() hostnames[hostname] = index diff --git a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift index bf9f1dd9..6f03145c 100644 --- a/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift +++ b/Sources/Services/ContainerNetworkService/AttachmentConfiguration.swift @@ -33,7 +33,10 @@ public struct AttachmentOptions: Codable, Sendable { /// The hostname associated with the attachment. public let hostname: String - public init(hostname: String) { + public let ip: String? + + public init(hostname: String, ip: String? = nil) { self.hostname = hostname + self.ip = ip } } diff --git a/Sources/Services/ContainerNetworkService/NetworkClient.swift b/Sources/Services/ContainerNetworkService/NetworkClient.swift index cde0b154..b10fd638 100644 --- a/Sources/Services/ContainerNetworkService/NetworkClient.swift +++ b/Sources/Services/ContainerNetworkService/NetworkClient.swift @@ -46,9 +46,12 @@ extension NetworkClient { return state } - public func allocate(hostname: String) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + public func allocate(hostname: String, ip: String?) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + if let ip { + request.set(key: NetworkKeys.ip.rawValue, value: ip) + } let client = createClient() @@ -120,6 +123,14 @@ extension XPCMessage { return hostname } + func ip() throws -> String? { + let ip = self.string(key: NetworkKeys.ip.rawValue) + guard let ip else { + return nil + } + return ip + } + func state() throws -> NetworkState { let data = self.dataNoCopy(key: NetworkKeys.state.rawValue) guard let data else { diff --git a/Sources/Services/ContainerNetworkService/NetworkKeys.swift b/Sources/Services/ContainerNetworkService/NetworkKeys.swift index ccbeb8db..f86efbac 100644 --- a/Sources/Services/ContainerNetworkService/NetworkKeys.swift +++ b/Sources/Services/ContainerNetworkService/NetworkKeys.swift @@ -21,4 +21,5 @@ public enum NetworkKeys: String { case hostname case network case state + case ip } diff --git a/Sources/Services/ContainerNetworkService/NetworkService.swift b/Sources/Services/ContainerNetworkService/NetworkService.swift index 9267dfe8..757920fb 100644 --- a/Sources/Services/ContainerNetworkService/NetworkService.swift +++ b/Sources/Services/ContainerNetworkService/NetworkService.swift @@ -59,7 +59,13 @@ public actor NetworkService: Sendable { } let hostname = try message.hostname() - let index = try await allocator.allocate(hostname: hostname) + var index: UInt32 + if let staticIpString = try message.ip() { + let staticIp = try IPv4Address(staticIpString) + index = try await allocator.allocate(hostname: hostname, staticIndex: staticIp.value) + } else { + index = try await allocator.allocate(hostname: hostname) + } let subnet = try CIDRAddress(status.address) let ip = IPv4Address(fromValue: index) let attachment = Attachment( diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift index 35d8a1c8..103f07b0 100644 --- a/Sources/Services/ContainerSandboxService/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -142,7 +142,7 @@ public actor SandboxService { for index in 0.. 0) + let cidrAddress = try CIDRAddress(container.networks[0].address) + #expect(cidrAddress.address.description == staticIp, "expected static ip \(staticIp), got \(cidrAddress.address.description)") + + try doStop(name: name) + } catch { + Issue.record("failed to use a static IP in network \(error)") + return + } + } + @available(macOS 26, *) @Test func testNetworkDeleteWithContainer() async throws { do { diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 3bfb8c0e..a38de4a9 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -181,6 +181,7 @@ class CLITest { case invalidInput(String) case invalidOutput(String) case containerNotFound(String) + case networkNotFound(String) case containerRunFailed(String) case binaryNotFound case binaryAttributesNotFound(Error) @@ -353,6 +354,36 @@ class CLITest { return io[0].name } + func inspectNetwork(_ name: String) throws -> NetworkInspectOutput { + let response = try run(arguments: [ + "network", + "inspect", + name, + ]) + let cmdStatus = response.status + guard cmdStatus == 0 else { + throw CLIError.executionFailed("network inspect failed: exit \(cmdStatus)") + } + + let output = response.output + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("network inspect output invalid") + } + + let decoder = JSONDecoder() + + typealias inspectOutputs = [NetworkInspectOutput] + + let io = try decoder.decode(inspectOutputs.self, from: jsonData) + guard io.count > 0 else { + throw CLIError.networkNotFound(name) + } + guard io.count == 1 else { + throw CLIError.invalidOutput("network inspect output invalid, multiple networks with same name") + } + return io[0] + } + func doPull(imageName: String, args: [String]? = nil) throws { var pullArgs = [ "image", diff --git a/docs/command-reference.md b/docs/command-reference.md index 0631c215..1826c311 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -41,7 +41,7 @@ container run [OPTIONS] IMAGE [COMMAND] [ARG...] * `-l, --label