diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift index 78a207467..dd0996283 100644 --- a/Sources/APIServer/ContainerDNSHandler.swift +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -76,10 +76,12 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost(question: Question) async throws -> ResourceRecord? { - guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + guard let ipAllocation = try await networkService.lookup(hostname: question.name), + let ipv4Address = ipAllocation.ipv4Address + else { return nil } - let ipv4 = ipAllocation.ipv4Address.address.description + let ipv4 = ipv4Address.address.description guard let ip = try? IPv4Address(ipv4) else { throw DNSResolverError.serverError("failed to parse IP address: \(ipv4)") } diff --git a/Sources/ContainerCommands/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift index a7b102f93..54698cbe6 100644 --- a/Sources/ContainerCommands/Builder/BuilderStatus.swift +++ b/Sources/ContainerCommands/Builder/BuilderStatus.swift @@ -81,7 +81,7 @@ private struct PrintableBuilder: ListDisplayable { snapshot.id, snapshot.configuration.image.reference, snapshot.status.rawValue, - snapshot.networks.map { $0.ipv4Address.description }.joined(separator: ","), + snapshot.networks.map { $0.ipv4Address?.description ?? "" }.joined(separator: ","), "\(snapshot.configuration.resources.cpus)", "\(snapshot.configuration.resources.memoryInBytes / (1024 * 1024)) MB", ] diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift index a61cedc0e..720d9491d 100644 --- a/Sources/ContainerCommands/Container/ContainerList.swift +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -52,3 +52,41 @@ extension Application { } } } + +extension PrintableContainer: ListDisplayable { + static var tableHeader: [String] { + ["ID", "IMAGE", "OS", "ARCH", "STATE", "IP", "CPUS", "MEMORY", "STARTED"] + } + + var tableRow: [String] { + [ + self.configuration.id, + self.configuration.image.reference, + self.configuration.platform.os, + self.configuration.platform.architecture, + self.status.rawValue, + self.networks.map { $0.ipv4Address?.description ?? "" }.joined(separator: ","), + "\(self.configuration.resources.cpus)", + "\(self.configuration.resources.memoryInBytes / (1024 * 1024)) MB", + self.startedDate?.ISO8601Format() ?? "", + ] + } + + var quietValue: String { + self.configuration.id + } +} + +struct PrintableContainer: Codable, Sendable { + let status: RuntimeStatus + let configuration: ContainerConfiguration + let networks: [Attachment] + let startedDate: Date? + + init(_ container: ContainerSnapshot) { + self.status = container.status + self.configuration = container.configuration + self.networks = container.networks + self.startedDate = container.startedDate + } +} diff --git a/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift b/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift index f70791ce6..59b5751f6 100644 --- a/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift +++ b/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift @@ -29,7 +29,7 @@ extension ManagedContainer: ListDisplayable { configuration.platform.os, configuration.platform.architecture, status.state.rawValue, - status.networks.map { $0.ipv4Address.description }.joined(separator: ","), + status.networks.map { $0.ipv4Address?.description ?? "" }.joined(separator: ","), "\(configuration.resources.cpus)", "\(configuration.resources.memoryInBytes / (1024 * 1024)) MB", status.startedDate?.ISO8601Format() ?? "", diff --git a/Sources/ContainerK8s/K8sHelper.swift b/Sources/ContainerK8s/K8sHelper.swift index 2bc4c9d7f..706b38cf1 100644 --- a/Sources/ContainerK8s/K8sHelper.swift +++ b/Sources/ContainerK8s/K8sHelper.swift @@ -150,7 +150,7 @@ struct K8sNodeResource: ManagedResource, ListDisplayable { var tableRow: [String] { let role = snapshot.configuration.labels[ResourceLabelKeys.role] ?? "" - let addr = snapshot.networks.map { $0.ipv4Address.address.description }.joined(separator: ",") + let addr = snapshot.networks.map { $0.ipv4Address?.address.description ?? "" }.joined(separator: ",") let memoryMB = snapshot.configuration.resources.memoryInBytes / (1024 * 1024) let ports = snapshot.configuration.publishedPorts .map { "\($0.hostPort)->\($0.containerPort)" } diff --git a/Sources/ContainerK8s/Provisioners/LinuxNodeProvisioner.swift b/Sources/ContainerK8s/Provisioners/LinuxNodeProvisioner.swift index c50f7d02b..7e4d3529c 100644 --- a/Sources/ContainerK8s/Provisioners/LinuxNodeProvisioner.swift +++ b/Sources/ContainerK8s/Provisioners/LinuxNodeProvisioner.swift @@ -159,7 +159,7 @@ public struct LinuxNodeProvisioner: NodeProvisioner { public func address(name: String, log: Logger) async throws -> String { let client = ContainerClient() let snapshot = try await client.get(id: name) - guard let ip = snapshot.networks.first?.ipv4Address.address.description else { + guard let ip = snapshot.networks.first?.ipv4Address?.address.description else { throw ContainerizationError(.internalError, message: "no VM IP for node \(name)") } return ip diff --git a/Sources/ContainerResource/Network/Attachment.swift b/Sources/ContainerResource/Network/Attachment.swift index a6351ab3b..f10b86668 100644 --- a/Sources/ContainerResource/Network/Attachment.swift +++ b/Sources/ContainerResource/Network/Attachment.swift @@ -23,9 +23,11 @@ public struct Attachment: Codable, Sendable { /// The hostname associated with the attachment. public let hostname: String /// The CIDR address describing the interface IPv4 address, with the prefix length of the subnet. - public let ipv4Address: CIDRv4 + /// Nil for bridge-mode attachments where the address is assigned by DHCP at runtime. + public let ipv4Address: CIDRv4? /// The IPv4 gateway address. - public let ipv4Gateway: IPv4Address + /// Nil for bridge-mode attachments where the gateway is discovered via DHCP at runtime. + public let ipv4Gateway: IPv4Address? /// The CIDR address describing the interface IPv6 address, with the prefix length of the subnet. /// The address is nil if the IPv6 subnet could not be determined at network creation time. public let ipv6Address: CIDRv6? @@ -39,8 +41,8 @@ public struct Attachment: Codable, Sendable { public init( network: String, hostname: String, - ipv4Address: CIDRv4, - ipv4Gateway: IPv4Address, + ipv4Address: CIDRv4?, + ipv4Gateway: IPv4Address?, ipv6Address: CIDRv6?, macAddress: MACAddress?, mtu: UInt32? = nil, @@ -77,16 +79,12 @@ public struct Attachment: Codable, Sendable { network = try container.decode(String.self, forKey: .network) hostname = try container.decode(String.self, forKey: .hostname) - if let address = try? container.decode(CIDRv4.self, forKey: .ipv4Address) { - ipv4Address = address - } else { - ipv4Address = try container.decode(CIDRv4.self, forKey: .address) - } - if let gateway = try? container.decode(IPv4Address.self, forKey: .ipv4Gateway) { - ipv4Gateway = gateway - } else { - ipv4Gateway = try container.decode(IPv4Address.self, forKey: .gateway) - } + ipv4Address = + try container.decodeIfPresent(CIDRv4.self, forKey: .ipv4Address) + ?? container.decodeIfPresent(CIDRv4.self, forKey: .address) + ipv4Gateway = + try container.decodeIfPresent(IPv4Address.self, forKey: .ipv4Gateway) + ?? container.decodeIfPresent(IPv4Address.self, forKey: .gateway) ipv6Address = try container.decodeIfPresent(CIDRv6.self, forKey: .ipv6Address) macAddress = try container.decodeIfPresent(MACAddress.self, forKey: .macAddress) mtu = try container.decodeIfPresent(UInt32.self, forKey: .mtu) @@ -99,8 +97,8 @@ public struct Attachment: Codable, Sendable { try container.encode(network, forKey: .network) try container.encode(hostname, forKey: .hostname) - try container.encode(ipv4Address, forKey: .ipv4Address) - try container.encode(ipv4Gateway, forKey: .ipv4Gateway) + try container.encodeIfPresent(ipv4Address, forKey: .ipv4Address) + try container.encodeIfPresent(ipv4Gateway, forKey: .ipv4Gateway) try container.encodeIfPresent(ipv6Address, forKey: .ipv6Address) try container.encodeIfPresent(macAddress, forKey: .macAddress) try container.encodeIfPresent(mtu, forKey: .mtu) diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index 612963286..daec218e4 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -288,6 +288,12 @@ extension XPCMessage { } } + public func xpcDictionary(key: String) -> xpc_object_t? { + lock.withLock { + xpc_dictionary_get_dictionary(self.object, key) + } + } + public func endpoint(key: String) -> xpc_endpoint_t? { lock.withLock { xpc_dictionary_get_value(self.object, key) diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift index e447bcce7..12847344c 100644 --- a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -28,12 +28,8 @@ import Foundation import Logging import SystemPackage -enum Variant: String, ExpressibleByArgument { - case reserved - case allocationOnly -} - extension NetworkMode: ExpressibleByArgument {} +extension NetworkVariant: ExpressibleByArgument {} extension NetworkVmnetHelper { struct Start: AsyncParsableCommand { @@ -61,17 +57,26 @@ extension NetworkVmnetHelper { var ipv6Subnet: String? @Option(name: .long, help: "Variant of the network helper to use.") - var variant: Variant = { + var variant: NetworkVariant = { guard #available(macOS 26, *) else { return .allocationOnly } return .reserved }() + @Option(name: .customLong("option-json"), help: "UTF8-encoded JSON string that contains the configuration options for this network") + var stringifiedOptions: String = "{}" + var logRoot = LogRoot.path func run() async throws { let commandName = NetworkVmnetHelper._commandName + guard let encodedOptions = stringifiedOptions.data(using: .utf8), + let options = try? JSONSerialization.jsonObject(with: encodedOptions) as? [String: String] + else { + throw ContainerizationError(.invalidArgument, message: "failed to decode network configuration options from JSON string") + } + let logPath = logRoot.map { $0.appending("\(commandName)-\(id).log") } let log = ServiceLogger.bootstrap(category: "NetworkVmnetHelper", metadata: ["id": "\(id)"], debug: debug, logPath: logPath) log.info("starting helper", metadata: ["name": "\(commandName)"]) @@ -90,7 +95,9 @@ extension NetworkVmnetHelper { ipv4Subnet: ipv4Subnet, ipv6Subnet: ipv6Subnet, plugin: NetworkVmnetHelper._commandName, - options: ["variant": self.variant.rawValue] + options: options.merging( + ["variant": self.variant.rawValue], + uniquingKeysWith: { _, new in new }) ) let network = try Self.createNetwork( configuration: configuration, @@ -98,7 +105,7 @@ extension NetworkVmnetHelper { log: log ) try await network.start() - let service = try await DefaultNetworkService(network: network, log: log) + let service: NetworkService = try await Self.createNetworkService(network: network, variant: variant, log: log) let harness = NetworkHarness(service: service) let xpc = XPCServer( identifier: serviceIdentifier, @@ -123,7 +130,16 @@ extension NetworkVmnetHelper { } } - private static func createNetwork(configuration: NetworkConfiguration, variant: Variant, log: Logger) throws -> Network { + private static func createNetworkService(network: Network, variant: NetworkVariant, log: Logger) async throws -> NetworkService { + switch variant { + case .bridged, .bridgedViaHelper: + return try await BridgeNetworkService(network: network, variant: variant, log: log) + default: + return try await DefaultNetworkService(network: network, log: log) + } + } + + private static func createNetwork(configuration: NetworkConfiguration, variant: NetworkVariant, log: Logger) throws -> Network { switch variant { case .allocationOnly: return try AllocationOnlyVmnetNetwork(configuration: configuration, log: log) @@ -135,6 +151,8 @@ extension NetworkVmnetHelper { ) } return try ReservedVmnetNetwork(configuration: configuration, log: log) + case .bridged, .bridgedViaHelper: + return try BridgedVmnetNetwork(configuration: configuration, log: log) } } } diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 3a0ffab2f..517f6e222 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -69,6 +69,8 @@ extension RuntimeLinuxHelper { NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "allocationOnly"): IsolatedInterfaceStrategy() ] if #available(macOS 26, *) { + interfaceStrategies[NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "bridged")] = BridgedInterfaceStrategy(log: log) + interfaceStrategies[NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "bridgedViaHelper")] = BridgedInterfaceStrategy(log: log) interfaceStrategies[NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "reserved")] = NonisolatedInterfaceStrategy(log: log) } diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift index 7fe35fae8..eef41e118 100644 --- a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -395,6 +395,17 @@ public actor NetworksService { args += ["--variant", variant] } + // TODO: variant could possibly stay inside the options and does not need to be a dedicated commandline argument? + let options = configuration.options.filter({ key, _ in key != "variant" }) + if !options.isEmpty { + guard let encodedOptions = try? JSONSerialization.data(withJSONObject: options), + let stringifiedOptions = String(data: encodedOptions, encoding: .utf8) + else { + throw ContainerizationError(.internalError, message: "failed to encode network configuration options as json string") + } + args += ["--option-json", stringifiedOptions] + } + let entityPath = try store.entityPath(configuration.id) try pluginLoader.registerWithLaunchd( plugin: networkPlugin, diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index ff3df1fd0..4f29f15a6 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -162,7 +162,7 @@ public actor MachinesService { self.log.warning("failed to fetch container addresses: \(error)") } let addressMap = (containers ?? []).reduce(into: [String: String]()) { result, c in - if let addr = c.networks.first?.ipv4Address.address.description { + if let addr = c.networks.first?.ipv4Address?.address.description { result[c.id] = addr } } @@ -546,7 +546,7 @@ public actor MachinesService { if snapshot.status == .running, let cid = snapshot.containerId { do { let container = try await self.client.get(id: cid) - snapshot.ipAddress = container.networks.first?.ipv4Address.address.description + snapshot.ipAddress = container.networks.first?.ipv4Address?.address.description } catch { self.log.warning("failed to fetch container address for \(cid): \(error)") } diff --git a/Sources/Services/Network/Client/BridgeNetworkKeys.swift b/Sources/Services/Network/Client/BridgeNetworkKeys.swift new file mode 100644 index 000000000..fd600f9bd --- /dev/null +++ b/Sources/Services/Network/Client/BridgeNetworkKeys.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +public enum BridgeNetworkKeys: String { + case hostInterface + case enableTso + case enableChecksumOffload + case bufferedPacketCount + case sandboxEndpoint +} diff --git a/Sources/Services/Network/Client/NetworkVariant.swift b/Sources/Services/Network/Client/NetworkVariant.swift new file mode 100644 index 000000000..a557081a1 --- /dev/null +++ b/Sources/Services/Network/Client/NetworkVariant.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +public enum NetworkVariant: String, Sendable { + case reserved + case allocationOnly + case bridged + case bridgedViaHelper +} diff --git a/Sources/Services/Network/Server/BridgeNetworkService.swift b/Sources/Services/Network/Server/BridgeNetworkService.swift new file mode 100644 index 000000000..ebf6b629c --- /dev/null +++ b/Sources/Services/Network/Server/BridgeNetworkService.swift @@ -0,0 +1,488 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkClient +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import vmnet + +internal final class Bridge { + + internal final class PacketBuffer { + public let buffers: UnsafeMutableRawPointer + public let iovs: UnsafeMutablePointer + public let packets: UnsafeMutablePointer + + public let maxPacketSize: Int + public let maxPktCount: Int + + public init(maxPktCount: Int, maxPktSize: Int) { + self.buffers = .allocate(byteCount: maxPktCount * maxPktSize, alignment: 16) + self.iovs = .allocate(capacity: maxPktCount) + self.packets = .allocate(capacity: maxPktCount) + for i in 0..sandbox + private let hostPacketBuffer: PacketBuffer + // sandbox->host + private let sandboxPacketBuffer: PacketBuffer + + public init( + identifier: String, + hostInterface: String, + logger: Logger, + enableTso: Bool, + enableChecksumOffload: Bool, + bufferedPacketCount: Int + ) throws { + var fds: [Int32] = [-1, -1] + let rc = fds.withUnsafeMutableBufferPointer { + socketpair(AF_UNIX, SOCK_DGRAM, 0, $0.baseAddress) + } + guard rc == 0 else { + throw ContainerizationError(.internalError, message: "unable to create socket pairs for UNIX bridge endpoints") + } + + let networkEndpoint = FileHandle(fileDescriptor: fds[0], closeOnDealloc: true) + let sandboxEndpoint = FileHandle(fileDescriptor: fds[1], closeOnDealloc: true) + + // Make our endpoint side non blocking, so the read source can drain it in a loop. + let flags = fcntl(networkEndpoint.fileDescriptor, F_GETFL) + _ = fcntl(networkEndpoint.fileDescriptor, F_SETFL, flags | O_NONBLOCK) + + // vmnet interface descriptor. + let desc = xpc_dictionary_create(nil, nil, 0) + xpc_dictionary_set_uint64( + desc, vmnet_operation_mode_key, + UInt64(vmnet.operating_modes_t.VMNET_BRIDGED_MODE.rawValue) + ) + xpc_dictionary_set_string(desc, vmnet_shared_interface_name_key, hostInterface) + xpc_dictionary_set_bool(desc, vmnet_enable_tso_key, enableTso) + xpc_dictionary_set_bool( + desc, vmnet_enable_checksum_offload_key, + enableChecksumOffload) + + // Unfortunately, apparently not understood currently - otherwise this would maybe provide us the opportunity to + // set a custom MAC address + // xpc_dictionary_set_bool(desc, vmnet_allocate_mac_address_key, false) + + // start interface synchronously — vmnet posts the completion on `queue`. + let queue = DispatchQueue(label: identifier, qos: .userInitiated) + let sema = DispatchSemaphore(value: 0) + var startStatus: vmnet_return_t = .VMNET_FAILURE + var maxPacketSize = 0 + var mtu: UInt32 = 0 + var mac = "" + + let iface = vmnet_start_interface(desc, queue) { status, param in + startStatus = status + if status == .VMNET_SUCCESS, let param = param { + maxPacketSize = Int(xpc_dictionary_get_uint64(param, vmnet_max_packet_size_key)) + mtu = UInt32(xpc_dictionary_get_uint64(param, vmnet_mtu_key)) + if let cstr = xpc_dictionary_get_string(param, vmnet_mac_address_key) { + mac = String(cString: cstr) + } + } + sema.signal() + } + sema.wait() + + guard startStatus == .VMNET_SUCCESS, let iface = iface else { + throw ContainerizationError(.internalError, message: "unable to start vmnet bridge interface") + } + guard let macAddress = try? MACAddress(mac) else { + throw ContainerizationError(.internalError, message: "vmnet returned an invalid mac address for vmnet bridge interface") + } + + self.log = logger + + self.identifier = identifier + self.networkEndpoint = networkEndpoint + self.sandboxEndpoint = sandboxEndpoint + self.macAddress = macAddress + self.mtu = mtu + self.interfaceRef = iface + self.queue = queue + + self.hostPacketBuffer = PacketBuffer(maxPktCount: bufferedPacketCount, maxPktSize: maxPacketSize) + self.sandboxPacketBuffer = PacketBuffer(maxPktCount: 1, maxPktSize: maxPacketSize) + } + + public func start() throws { + guard self.readSource == nil, !stopped else { + throw ContainerizationError(.invalidState, message: "bridge was already started") + } + + // TODO: despite Swift's claim that there are no function calls inside the event handlers that could throw exceptions, + // this is unfortunately simply not true. this is possibly connected due to some Objective-C legacy behavior + // of the vmnet_interface API. + // further investigation in catching these exceptions and making them visible might prove useful, since + // otherwise the network service can silently crash without any trace in the logs. the sandbox service will + // also keep running and will only notice that its interface has lost its carrier (quite fitting). only the + // console logging does provide then a crash dump including stack trace featuring some message like this: + // + // *** Terminating app due to uncaught exception 'NSFileHandleOperationException', reason: '*** -[NSConcreteFileHandle fileDescriptor]: Bad file descriptor' + + // host -> vm: vmnet fires an event when packets are buffered. + let st = vmnet_interface_set_event_callback( + interfaceRef, .VMNET_INTERFACE_PACKETS_AVAILABLE, queue + ) { [weak self] _, _ in + self?.relayFromHostToSandbox() + } + guard st == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "vmnet_interface_set_event_callback failed") + } + + // vm -> host: dispatch source on the socket. + let src = DispatchSource.makeReadSource(fileDescriptor: networkEndpoint.fileDescriptor, queue: queue) + src.setEventHandler { [weak self] in self?.relayFromSandboxToHost() } + src.resume() + self.readSource = src + } + + public func stop() throws { + guard !stopped else { + throw ContainerizationError(.invalidState, message: "bridge was already stopped") + } + stopped = true + + guard let readSource else { + throw ContainerizationError(.internalError, message: "read source for network endpoint has not been created") + } + readSource.cancel() + + let sema = DispatchSemaphore(value: 0) + let st = vmnet_stop_interface(interfaceRef, queue, { _ in sema.signal() }) + sema.wait() + + guard st == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to stop vmnet bridge interface") + } + } + + // bridge input and output helpers + private func readFromHost() -> Int32 { + hostPacketBuffer.reset() + var count = Int32(hostPacketBuffer.maxPktCount) + let st = vmnet_read(interfaceRef, hostPacketBuffer.packets, &count) + guard st == .VMNET_SUCCESS, count > 0 else { + return -1 + } + return count + } + + private func writeToSandbox(count: Int) -> Int { + var written = 0 + for i in 0.. 0 else { + log.warning("received invalid number of packets from host: \(pktsIn)") + return + } + let pktsOut = writeToSandbox(count: pktsIn) + guard pktsOut == pktsIn else { + log.warning("could not forward all packets to sandbox: \(pktsIn) != \(pktsOut)") + return + } + } + + private func relayFromSandboxToHost() { + // vmnet-helper uses Apple-private system calls sendmsg_x/recmsg_x to forward multiple packets + // in a single system call on the bridge... that sounds intuitively as it will likely achieve quite a + // performance improvement, but it also might break without further notice + // + // in doubt: this is just a fallback for the likely even more optimized internal VirtualizationFramework + // bridging functionality + let pktSize = read( + networkEndpoint.fileDescriptor, sandboxPacketBuffer.buffers, + sandboxPacketBuffer.maxPacketSize) + guard pktSize > 0 else { + log.warning("failed to read from sandbox socket: \(pktSize) - EOF?") + return + } + + sandboxPacketBuffer.iovs.pointee.iov_len = pktSize + sandboxPacketBuffer.packets.pointee.vm_pkt_size = pktSize + sandboxPacketBuffer.packets.pointee.vm_flags = 0 + + var pktCount: Int32 = 1 + guard vmnet_write(interfaceRef, sandboxPacketBuffer.packets, &pktCount) == .VMNET_SUCCESS, pktCount == 1 else { + log.warning("failed to forward packet from sandbox to host") + return + } + } + +} + +public actor BridgeNetworkService: NetworkService { + + private let network: any Network + private let variant: NetworkVariant + private let log: Logger + private var allocationsBySession: [XPCServerSession: [(hostname: String, bridge: Bridge?)]] + private var macAddresses: [String: MACAddress] + + /// Set up a network service for the specified network. + public init( + network: any Network, + variant: NetworkVariant, + log: Logger + ) async throws { + guard await network.status != nil else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + + self.network = network + self.variant = variant + self.log = log + self.allocationsBySession = [:] + self.macAddresses = [:] + } + + @Sendable + public func status() async throws -> NetworkStatus { + guard let status = await network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) is not running") + } + return status + } + + @Sendable + public func allocate( + hostname: String, + macAddress: MACAddress?, + session: XPCServerSession + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + log.debug("enter", metadata: ["func": "\(#function)"]) + defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard await network.status != nil else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + + // retrieve configuration from the network + // this is a bit awkward compared to the regular purpose of the network in managing allocations, since this is all + // handled externally by the network infrastructure on the other side of the bridge. therefore the network is for us + // simply a quite overblown struct/adapter that passes information along that are still unknown during creation time. + var additionalData: XPCMessage? + try network.withAdditionalData { + additionalData = $0 + } + + guard let bridgeDictionary = additionalData?.xpcDictionary(key: NetworkKeys.additionalData.rawValue) else { + throw ContainerizationError(.internalError, message: "did not receive bridge dictionary in network additional message") + } + let bridgeMessage = XPCMessage(object: bridgeDictionary) + + guard let hostInterface = bridgeMessage.string(key: BridgeNetworkKeys.hostInterface.rawValue) + else { + throw ContainerizationError(.invalidState, message: "bridge network is not assigned to a host interface") + } + + var attachment: Attachment + var bridge: Bridge? + if variant == .bridged { + let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) + attachment = Attachment( + network: network.id, + hostname: hostname, + ipv4Address: nil, + ipv4Gateway: nil, + ipv6Address: nil, + macAddress: macAddress, + mtu: nil, + ) + bridge = nil + } else { + let enableTso = + bridgeMessage.dataNoCopy(key: BridgeNetworkKeys.enableTso.rawValue) != nil ? bridgeMessage.bool(key: BridgeNetworkKeys.enableTso.rawValue) : nil + let enableChecksumOffload = + bridgeMessage.dataNoCopy(key: BridgeNetworkKeys.enableChecksumOffload.rawValue) != nil + ? bridgeMessage.bool(key: BridgeNetworkKeys.enableChecksumOffload.rawValue) : nil + let batchSize = + bridgeMessage.dataNoCopy(key: BridgeNetworkKeys.bufferedPacketCount.rawValue) != nil + ? Int(bridgeMessage.uint64(key: BridgeNetworkKeys.bufferedPacketCount.rawValue)) : nil + + bridge = try! Bridge( + identifier: "vmnet-bridge-\(network.id)-\(hostname)", hostInterface: hostInterface, logger: log, enableTso: enableTso ?? false, + enableChecksumOffload: enableChecksumOffload ?? false, bufferedPacketCount: batchSize ?? 64) + try bridge!.start() + + attachment = Attachment( + network: network.id, + hostname: hostname, + ipv4Address: nil, + ipv4Gateway: nil, + ipv6Address: nil, + macAddress: bridge!.macAddress, + mtu: bridge!.mtu + ) + + // add the sandbox-side fd endpoint to the bridge message + bridgeMessage.set(key: BridgeNetworkKeys.sandboxEndpoint.rawValue, value: bridge!.sandboxEndpoint) + + // close the sandbox endpoint's fd, since we do not need it anymore and it has been just dup-ed + // when we attached it to the XPC message above + try bridge!.sandboxEndpoint.close() + } + + log.info( + "allocated attachment", + metadata: [ + "hostname": "\(hostname)", + "hostInterface": "\(hostInterface)", + "macAddress": "\(attachment.macAddress!)", + "bridge": "\(bridge?.identifier ?? "vf-based")", + ]) + + if allocationsBySession[session] == nil { + allocationsBySession[session] = [] + await session.onDisconnect { [weak self] in + await self?.releaseSession(session) + } + } + allocationsBySession[session]!.append((hostname: hostname, bridge: bridge)) + macAddresses[hostname] = attachment.macAddress + + return (attachment: attachment, additionalData: additionalData) + } + + private func releaseSession(_ session: XPCServerSession) async { + guard let allocations = allocationsBySession.removeValue(forKey: session) else { + return + } + for allocation: (hostname: String, bridge: Bridge?) in allocations { + macAddresses[allocation.hostname] = nil + guard let bridge = allocation.bridge else { + continue + } + do { + try bridge.stop() + } catch let error as ContainerizationError { + log.error( + "failed to stop bridge for attachment", + metadata: [ + "error": Logger.MetadataValue.string(error.message) + ]) + } catch { + assert(false, "should never happen") + } + } + log.info("released session", metadata: ["allocations": "\(allocations.count)"]) + } + + @Sendable + public func lookup(hostname: String) async throws -> Attachment? { + log.debug("enter", metadata: ["func": "\(#function)"]) + defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard await network.status != nil else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + guard let macAddress = macAddresses[hostname] else { + log.warning("MAC address for hostname \(hostname) is not a valid attachment") + return nil + } + + let attachment = Attachment( + network: network.id, + hostname: hostname, + ipv4Address: nil, + ipv4Gateway: nil, + ipv6Address: nil, + macAddress: macAddress + ) + log.info( + "lookup attachment", + metadata: [ + "hostname": "\(hostname)", + "macAddress": "\(macAddress)", + ]) + + return attachment + } +} diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift index 70d17d396..a032bf572 100644 --- a/Sources/Services/Network/Server/DefaultNetworkService.swift +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -84,8 +84,8 @@ public actor DefaultNetworkService: NetworkService { "allocated attachment", metadata: [ "hostname": "\(hostname)", - "ipv4Address": "\(attachment.ipv4Address)", - "ipv4Gateway": "\(attachment.ipv4Gateway)", + "ipv4Address": "\(attachment.ipv4Address?.description ?? "none")", + "ipv4Gateway": "\(attachment.ipv4Gateway?.description ?? "none")", "ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")", "macAddress": "\(attachment.macAddress?.description ?? "unspecified")", ]) diff --git a/Sources/Services/NetworkVmnet/Server/BridgedVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/BridgedVmnetNetwork.swift new file mode 100644 index 000000000..d7570786a --- /dev/null +++ b/Sources/Services/NetworkVmnet/Server/BridgedVmnetNetwork.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkClient +import ContainerNetworkServer +import ContainerResource +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import Synchronization +import Virtualization +import XPC + +public final class BridgedVmnetNetwork: ContainerNetworkServer.Network { + // FIXME: NetworkPluginStatus requires non-optional ipv4Subnet/ipv4Gateway; use placeholder + // values until the type is refactored to make them optional. + private static let placeholderSubnet = try! CIDRv4("0.0.0.0/0") + private static let placeholderGateway = IPv4Address(0) + private static let placeholderSubnetv6 = try! CIDRv6("::/0") + + private let log: Logger + private let configuration: NetworkConfiguration + private let hostInterface: String + private let statusMutex: Mutex + private let enableTso: Bool? + private let enableChecksumOffload: Bool? + private let bufferedPacketCount: Int? + + public init(configuration: NetworkConfiguration, log: Logger) throws { + // TODO: ignore the network type for now - having both network type and plugin variant is a bit awkward... + // I imagine this will get resolved later on + // guard configuration.mode == .bridge else { + // throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") + // } + guard let variantStr = configuration.options["variant"], + let variant = NetworkVariant(rawValue: variantStr), + variant == .bridged || variant == .bridgedViaHelper + else { + throw ContainerizationError( + .unsupported, + message: "invalid network variant \(configuration.options["variant"] ?? "unspecified")") + } + + guard let hostInterface = configuration.options["hostInterface"] else { + throw ContainerizationError(.invalidArgument, message: "hostInterface must be given as a plugin option") + } + let available = VZBridgedNetworkInterface.networkInterfaces.map { $0.identifier } + guard available.contains(hostInterface) else { + let list = available.isEmpty ? "none available" : available.joined(separator: ", ") + throw ContainerizationError(.invalidArgument, message: "no host interface '\(hostInterface)'; available: \(list)") + } + self.hostInterface = hostInterface + + self.configuration = configuration + self.log = log + self.statusMutex = Mutex(nil) + + if variant == .bridged { + self.enableTso = nil + self.enableChecksumOffload = nil + self.bufferedPacketCount = nil + } else { + let rawEnableTso = configuration.options[BridgeNetworkKeys.enableTso.rawValue] + if rawEnableTso != nil { + guard let enableTso = Bool(rawEnableTso!) else { + throw ContainerizationError(.invalidArgument, message: "enableTso must be a boolean") + } + self.enableTso = enableTso + } else { + self.enableTso = nil + } + + let rawEnableChecksumOffload: String? = configuration.options[BridgeNetworkKeys.enableChecksumOffload.rawValue] + if rawEnableChecksumOffload != nil { + guard let enableChecksumOffload = Bool(rawEnableChecksumOffload!) else { + throw ContainerizationError(.invalidArgument, message: "enableChecksumOffload must be a boolean") + } + self.enableChecksumOffload = enableChecksumOffload + } else { + self.enableChecksumOffload = nil + } + + let rawBufferedPacketCount = configuration.options[BridgeNetworkKeys.bufferedPacketCount.rawValue] + if rawBufferedPacketCount != nil { + guard let bufferedPacketCount = Int(rawBufferedPacketCount!), bufferedPacketCount > 0 else { + throw ContainerizationError(.invalidArgument, message: "bufferedPacketCount must be a positive integer") + } + self.bufferedPacketCount = bufferedPacketCount + } else { + self.bufferedPacketCount = nil + } + } + } + + public nonisolated var id: String { configuration.id } + + public nonisolated var variant: String? { "bridgedVmnet" } + + public var status: NetworkStatus? { + self.statusMutex.withLock { $0 } + } + + public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + let bridgeConfigMsg = XPCMessage(object: xpc_dictionary_create_empty()) + bridgeConfigMsg.set(key: BridgeNetworkKeys.hostInterface.rawValue, value: hostInterface) + if self.enableTso != nil { + bridgeConfigMsg.set(key: BridgeNetworkKeys.enableTso.rawValue, value: self.enableTso!) + } + if self.enableChecksumOffload != nil { + bridgeConfigMsg.set(key: BridgeNetworkKeys.enableChecksumOffload.rawValue, value: self.enableChecksumOffload!) + } + if self.bufferedPacketCount != nil { + bridgeConfigMsg.set(key: BridgeNetworkKeys.bufferedPacketCount.rawValue, value: UInt64(self.bufferedPacketCount!)) + } + + let msg: XPCMessage = XPCMessage(object: xpc_dictionary_create_empty()) + msg.set(key: NetworkKeys.additionalData.rawValue, value: bridgeConfigMsg.underlying) + try handler(msg) + } + + public func start() async throws { + try statusMutex.withLock { status in + + guard status == nil else { + throw ContainerizationError(.invalidArgument, message: "cannot start network \(configuration.id): already started") + } + + status = NetworkStatus( + ipv4Subnet: Self.placeholderSubnet, ipv4Gateway: Self.placeholderGateway, ipv6Subnet: Self.placeholderSubnetv6 + ) + + log.info( + "started bridged network", + metadata: [ + "id": "\(configuration.id)", + "hostInterface": "\(hostInterface)", + ] + ) + } + } +} diff --git a/Sources/Services/RuntimeLinux/Server/BridgedInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/BridgedInterfaceStrategy.swift new file mode 100644 index 000000000..29ff55f1b --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/BridgedInterfaceStrategy.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkClient +import ContainerResource +import ContainerRuntimeClient +import ContainerXPC +import Containerization +import ContainerizationError +import Logging + +/// Interface strategy for containers that use macOS's custom network feature. +@available(macOS 26, *) +public struct BridgedInterfaceStrategy: InterfaceStrategy { + private let log: Logger + + public init(log: Logger) { + self.log = log + } + + public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface { + guard let bridgeDictionary = additionalData?.xpcDictionary(key: NetworkKeys.additionalData.rawValue) else { + throw ContainerizationError(.internalError, message: "did not receive bridge dictionary in interface additional message") + } + let bridgeData = XPCMessage(object: bridgeDictionary) + + guard let ifaceName = bridgeData.string(key: BridgeNetworkKeys.hostInterface.rawValue) + else { + throw ContainerizationError(.invalidState, message: "bridge network missing host interface name") + } + guard let containerBridgeEndpoint = bridgeData.fileHandle(key: BridgeNetworkKeys.sandboxEndpoint.rawValue) else { + return BridgedNetworkInterface( + hostInterfaceName: ifaceName, + macAddress: attachment.macAddress + ) + } + + return FileHandleNetworkInterface( + fileHandle: containerBridgeEndpoint, + macAddress: attachment.macAddress + ) + } +} diff --git a/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift index d180fac5c..ac975b48e 100644 --- a/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift +++ b/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift @@ -18,6 +18,7 @@ import ContainerResource import ContainerRuntimeClient import ContainerXPC import Containerization +import ContainerizationError /// Isolated container network interface strategy. This strategy prohibits /// container to container networking, but it is the only approach that @@ -25,10 +26,13 @@ import Containerization public struct IsolatedInterfaceStrategy: InterfaceStrategy { public init() {} - public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface { + public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface { + guard let ipv4Address = attachment.ipv4Address else { + throw ContainerizationError(.invalidState, message: "NAT attachment missing IPv4 address") + } let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil return NATInterface( - ipv4Address: attachment.ipv4Address, + ipv4Address: ipv4Address, ipv4Gateway: ipv4Gateway, macAddress: attachment.macAddress, // https://github.com/apple/containerization/pull/38 diff --git a/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift index 38c1b6764..ea1602d65 100644 --- a/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift +++ b/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift @@ -42,10 +42,13 @@ public struct NonisolatedInterfaceStrategy: InterfaceStrategy { throw ContainerizationError(.invalidState, message: "cannot deserialize custom network reference, status \(status)") } + guard let ipv4Address = attachment.ipv4Address else { + throw ContainerizationError(.invalidState, message: "NAT attachment missing IPv4 address") + } log.info("creating NATNetworkInterface with network reference") let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil return NATNetworkInterface( - ipv4Address: attachment.ipv4Address, + ipv4Address: ipv4Address, ipv4Gateway: ipv4Gateway, reference: networkRef, macAddress: attachment.macAddress, diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 951404a30..ddbc8c73e 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -172,12 +172,6 @@ public actor RuntimeService { } kernel.commandLine.kernelArgs.append("\(key)=\(value)") } - let vmm = VZVirtualMachineManager( - kernel: kernel, - initialFilesystem: bundle.initialFilesystem.asMount, - rosetta: config.rosetta, - logger: self.log - ) let networkBootstrapInfos = try message.networkBootstrapInfos() @@ -207,6 +201,12 @@ public actor RuntimeService { variant: attachment.variant ) } + + // enable DHCP if the attachment has not been assigned an explicit IP address + if attachment.ipv4Address == nil { + kernel.commandLine.kernelArgs.append("ip=::::\(attachment.hostname):eth\(index):dhcp") + } + guard let iStrategy = self.interfaceStrategies[NetworkInterfaceKey(plugin: info.plugin, variant: attachment.variant)] else { throw ContainerizationError( .internalError, @@ -225,17 +225,23 @@ public actor RuntimeService { throw error } + let vmm = VZVirtualMachineManager( + kernel: kernel, + initialFilesystem: bundle.initialFilesystem.asMount, + rosetta: config.rosetta, + logger: self.log + ) + // Dynamically configure the DNS nameserver from a network if no explicit configuration + // For bridge networks (unspecified gateway), nameservers and domain come from DHCP (/proc/net/pnp). if let dns = config.dns, dns.nameservers.isEmpty { let defaultNameservers = self.getDefaultNameservers(from: attachments) - if !defaultNameservers.isEmpty { - config.dns = ContainerConfiguration.DNSConfiguration( - nameservers: defaultNameservers, - domain: dns.domain, - searchDomains: dns.searchDomains, - options: dns.options - ) - } + config.dns = ContainerConfiguration.DNSConfiguration( + nameservers: defaultNameservers.isEmpty ? dns.nameservers : defaultNameservers, + domain: defaultNameservers.isEmpty ? nil : dns.domain, + searchDomains: dns.searchDomains, + options: dns.options + ) } let stdio = message.stdio() @@ -272,11 +278,10 @@ public actor RuntimeService { // NOTE: We can support a user providing new entries eventually, but for now craft // a default /etc/hosts. var hostsEntries = [Hosts.Entry.localHostIPV4()] - if !interfaces.isEmpty { - let primaryIfaceAddr = interfaces[0].ipv4Address + if !interfaces.isEmpty, let ipv4Address = interfaces[0].ipv4Address, !ipv4Address.address.isUnspecified { hostsEntries.append( Hosts.Entry( - ipAddress: primaryIfaceAddr.address.description, + ipAddress: ipv4Address.address.description, hostnames: [czConfig.hostname ?? id], )) } @@ -1023,7 +1028,10 @@ public actor RuntimeService { let containerIPAddress: String switch publishedPort.hostAddress { case .v4(_): - containerIPAddress = attachment.ipv4Address.address.description + guard let ipv4Address = attachment.ipv4Address else { + throw ContainerizationError(.invalidState, message: "cannot configure IPv4 port forwarding for container with unknown IPv4 address") + } + containerIPAddress = ipv4Address.address.description case .v6(_): guard let ipv6Address = attachment.ipv6Address else { throw ContainerizationError(.invalidState, message: "cannot configure IPv6 port forwarding for container with unknown IPv6 address") @@ -1204,7 +1212,10 @@ public actor RuntimeService { private nonisolated func getDefaultNameservers(from attachments: [Attachment]) -> [String] { for attachment in attachments { - return [attachment.ipv4Gateway.description] + guard let ipv4Gateway: IPv4Address = attachment.ipv4Gateway else { + continue + } + return [ipv4Gateway.description] } return [] } diff --git a/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift new file mode 100644 index 000000000..00c8d6820 --- /dev/null +++ b/Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift @@ -0,0 +1,323 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import ContainerAPIClient +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Testing + +@Suite(.serialSuites, .serialized) +class TestCLINetwork: CLITest { + private static let retries = 10 + private static let retryDelaySeconds = Int64(3) + + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + private func getLowercasedTestName() -> String { + getTestName().lowercased() + } + + @available(macOS 26, *) + @Test func testNetworkCreateAndUse() async throws { + do { + let name = getLowercasedTestName() + let networkDeleteArgs = ["network", "delete", name] + _ = try? run(arguments: networkDeleteArgs) + + let networkCreateArgs = ["network", "create", name] + let result = try run(arguments: networkCreateArgs) + if result.status != 0 { + throw CLIError.executionFailed("command failed: \(result.error)") + } + defer { + _ = try? run(arguments: networkDeleteArgs) + } + + let listResult = try? run(arguments: ["network", "ls", "--quiet"]) + let networkIds = + listResult?.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter({ !$0.isEmpty }) + ?? ["OUTPUT MISSING"] + #expect(networkIds == networkIds.sorted(), "network IDs should be sorted") + + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: name, + image: "docker.io/library/python:alpine", + args: ["--network", name], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"]) + defer { + try? doStop(name: name) + } + + let container = try inspectContainer(name) + #expect(container.networks.count > 0) + let cidrAddress = try #require(container.networks[0].ipv4Address) + let url = "http://\(cidrAddress.address):\(port)" + var request = HTTPClientRequest(url: url) + request.method = .GET + let client = getClient(useHttpProxy: false) + defer { _ = client.shutdown() } + var retriesRemaining = Self.retries + var success = false + while !success && retriesRemaining > 0 { + do { + let response = try await client.execute(request, timeout: .seconds(Self.retryDelaySeconds)) + try #require(response.status == .ok) + success = true + } catch { + print("request to \(url) failed, error \(error)") + try await Task.sleep(for: .seconds(Self.retryDelaySeconds)) + } + retriesRemaining -= 1 + } + #expect(success, "Request to \(url) failed after \(Self.retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to create and use network \(error)") + return + } + } + + @available(macOS 26, *) + @Test func testNetworkDeleteWithContainer() async throws { + do { + // prep: delete container and network, ignoring if it doesn't exist + let name = getLowercasedTestName() + try? doRemove(name: name) + let networkDeleteArgs = ["network", "delete", name] + _ = try? run(arguments: networkDeleteArgs) + + // create our network + let networkCreateArgs = ["network", "create", name] + let networkCreateResult = try run(arguments: networkCreateArgs) + if networkCreateResult.status != 0 { + throw CLIError.executionFailed("command failed: \(networkCreateResult.error)") + } + + // ensure it's deleted + defer { + _ = try? run(arguments: networkDeleteArgs) + } + + // create a container that refers to the network + try doCreate(name: name, networks: [name]) + defer { + try? doRemove(name: name) + } + + // deleting the network should fail + let networkDeleteResult = try run(arguments: networkDeleteArgs) + try #require(networkDeleteResult.status != 0) + + // and should fail with a certain message + let msg = networkDeleteResult.error + #expect(msg.contains("delete failed")) + #expect(msg.contains("[\"\(name)\"]")) + + // now get rid of the container and its network reference + try? doRemove(name: name) + + // delete should succeed + _ = try run(arguments: networkDeleteArgs) + } catch { + Issue.record("failed to safely delete network \(error)") + return + } + } + + @available(macOS 26, *) + @Test func testNetworkLabels() async throws { + do { + // prep: delete container and network, ignoring if it doesn't exist + let name = getLowercasedTestName() + try? doRemove(name: name) + let networkDeleteArgs = ["network", "delete", name] + _ = try? run(arguments: networkDeleteArgs) + + // create our network + let networkCreateArgs = ["network", "create", "--label", "foo=bar", "--label", "baz=qux", name] + let networkCreateResult = try run(arguments: networkCreateArgs) + guard networkCreateResult.status == 0 else { + throw CLIError.executionFailed("command failed: \(networkCreateResult.error)") + } + + // ensure it's deleted + defer { + _ = try? run(arguments: networkDeleteArgs) + } + + // inspect the network + let networkInspectArgs = ["network", "inspect", name] + let networkInspectResult = try run(arguments: networkInspectArgs) + guard networkInspectResult.status == 0 else { + throw CLIError.executionFailed("command failed: \(networkInspectResult.error)") + } + + // decode the JSON result + let networkInspectOutput = networkInspectResult.output + guard let jsonData = networkInspectOutput.data(using: .utf8) else { + throw CLIError.invalidOutput("network inspect output invalid") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let networks = try decoder.decode([NetworkInspectOutput].self, from: jsonData) + guard networks.count == 1 else { + throw CLIError.invalidOutput("expected exactly one network from inspect, got \(networks.count)") + } + + // validate labels + + let expectedLabels = [ + "foo": "bar", + "baz": "qux", + ] + #expect(expectedLabels == networks[0].configuration.labels.dictionary) + + // delete should succeed + _ = try run(arguments: networkDeleteArgs) + } catch { + Issue.record("failed to safely delete network \(error)") + return + } + } + + @Test func testNetworkMTU() async throws { + let name = getLowercasedTestName() + try? doStop(name: name) + try? doRemove(name: name) + + try doLongRun(name: name, args: ["--network", "default,mtu=1500"]) + defer { try? doStop(name: name) } + + try waitForContainerRunning(name) + let output = try doExec(name: name, cmd: ["ip", "link", "show", "eth0"]) + #expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)") + } + + @available(macOS 26, *) + @Test func testIsolatedNetwork() async throws { + do { + let name = getLowercasedTestName() + let networkDeleteArgs = ["network", "delete", name] + _ = try? run(arguments: networkDeleteArgs) + + let networkCreateArgs = ["network", "create", "--internal", name] + let result = try run(arguments: networkCreateArgs) + if result.status != 0 { + throw CLIError.executionFailed("command failed: \(result.error)") + } + defer { + _ = try? run(arguments: networkDeleteArgs) + } + let port = UInt16.random(in: 50000..<60000) + try doLongRun( + name: name, + image: "docker.io/library/python:alpine", + args: ["--network", name], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"] + ) + defer { + try? doStop(name: name) + } + + let container = try inspectContainer(name) + #expect(container.networks.count > 0) + let curlImage = "docker.io/curlimages/curl:8.6.0" + let cidrAddress = try #require(container.networks[0].ipv4Address) + let url = "http://\(cidrAddress.address):\(port)" + let (_, _, _, succeed) = try run(arguments: [ + "run", + "--rm", + "--network", + name, + curlImage, + "curl", + url, + ]) + + #expect(succeed == 0, "internal connection should succeed") + + let (_, _, _, failed) = try run(arguments: [ + "run", + "--rm", + "--network", + name, + curlImage, + "curl", + "--connect-timeout", + "5", + "http://google.com", + ]) + + // hostOnly mode blocks off-host traffic; depending on whether vmnet/firewall + // rejects (7) or drops (28) packets, or DNS itself can't reach an external + // resolver (6), curl will fail with one of these codes. + let hostOnlyBlockedCodes: Set = [6, 7, 28] + #expect(hostOnlyBlockedCodes.contains(failed), "external connection should fail") + } + } + + @Test func testNetworkListTableFormat() throws { + let name = getLowercasedTestName() + _ = try? run(arguments: ["network", "delete", name]) + let createResult = try run(arguments: ["network", "create", name]) + if createResult.status != 0 { + throw CLIError.executionFailed("network create failed: \(createResult.error)") + } + defer { _ = try? run(arguments: ["network", "delete", name]) } + + let (_, output, error, status) = try run(arguments: ["network", "list"]) + #expect(status == 0, "network list should succeed, stderr: \(error)") + + let headers = ["NETWORK", "SUBNET"] + #expect(headers.allSatisfy { output.contains($0) }, "table should contain all headers") + #expect(output.contains(name), "table should contain the created network") + } + + @Test func testNetworkListJSONFormat() throws { + let name = getLowercasedTestName() + _ = try? run(arguments: ["network", "delete", name]) + let createResult = try run(arguments: ["network", "create", name]) + if createResult.status != 0 { + throw CLIError.executionFailed("network create failed: \(createResult.error)") + } + defer { _ = try? run(arguments: ["network", "delete", name]) } + + let (data, _, error, status) = try run(arguments: ["network", "list", "--format", "json"]) + #expect(status == 0, "network list --format json should succeed, stderr: \(error)") + + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] else { + Issue.record("JSON output should be an array of objects") + return + } + #expect(json.contains { ($0["id"] as? String) == name }, "JSON should contain the created network") + } + + @Test func testInspectMissingNetworkFails() throws { + let (_, _, error, status) = try run(arguments: ["network", "inspect", "definitely-missing-network"]) + #expect(status != 0, "Expected non-zero exit for missing network") + #expect(error.contains("network not found")) + } +} diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift new file mode 100644 index 000000000..2b9e12f3b --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -0,0 +1,944 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import ContainerAPIClient +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Testing + +// FIXME: We've split the tests into two suites to prevent swamping +// the API server with so many run commands that all wind up pulling +// images. +// +// When https://github.com/swiftlang/swift-testing/pull/1390 lands +// and is available on the CI runners, we can try setting the +// environment variable to limit concurrency and rejoin these suites. +@Suite(.serialSuites) +class TestCLIRunCommand1: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } + + @Test func testRunCommand() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: []) + defer { + try? doStop(name: name) + } + let _ = try doExec(name: name, cmd: ["date"]) + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCWD() throws { + do { + let name = getTestName() + let expectedCWD = "/tmp" + try doLongRun(name: name, args: ["--cwd", expectedCWD]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["pwd"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == expectedCWD, "expected current working directory to be \(expectedCWD), instead got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnv() throws { + do { + let name = getTestName() + let envData = "FOO=bar" + try doLongRun(name: name, args: ["--env", envData]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect( + inspectResp.configuration.initProcess.environment.contains(envData), + "environment variable \(envData) not set in container configuration") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnvFile() throws { + do { + let name = getTestName() + let content = """ + # Really cool comment + FOO=bar + BAR=baz wow + URL=https://foo.bar?baz=wow + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.env") + guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) else { + Issue.record("failed to create temporary file \(tempFile.path())") + return + } + defer { + try? FileManager.default.removeItem(at: tempFile) + } + try doLongRun(name: name, args: ["--env-file", tempFile.path()]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + let expected = [ + "FOO=bar", + "BAR=baz wow", + "URL=https://foo.bar?baz=wow", + ] + for item in expected { + #expect( + inspectResp.configuration.initProcess.environment.contains(item), + "environment variable \(item) not set in container configuration") + } + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUserIDGroupID() throws { + do { + let name = getTestName() + let uid = "10" + let gid = "100" + try doLongRun(name: name, args: ["--uid", uid, "--gid", gid]) + defer { + try? doStop(name: name) + } + + var output = try doExec(name: name, cmd: ["id"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + try #expect(output.contains(Regex("uid=\(uid).*?gid=\(gid).*")), "invalid user/group id, got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUser() throws { + do { + let name = getTestName() + let user = "nobody" + try doLongRun(name: name, args: ["--user", user]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["whoami"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == user, "expected user \(user), got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCPUs() throws { + do { + let name = getTestName() + let cpus = 2 + try doLongRun(name: name, args: ["--cpus", "\(cpus)"]) + defer { + try? doStop(name: name) + } + let cpusPath = "/sys/fs/cgroup/cpu.max" + let output = try doExec(name: name, cmd: ["cat", cpusPath]) + let fields = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .whitespaces) + #expect(fields.count == 2, "expected 2 fields in \(cpusPath), instead got \(fields.count)") + let numerator = try #require(Int(fields[0])) + let denominator = try #require(Int(fields[1])) + #expect(denominator > 0, "expected positive denominator in \(cpusPath), instead got \(denominator)") + let expectedNumerator = cpus * denominator + #expect(expectedNumerator == numerator, "expected \(expectedNumerator) in \(cpusPath), instead got \(numerator)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandMemory() throws { + do { + let name = getTestName() + let expectedMBs = 1024 + try doLongRun(name: name, args: ["--memory", "\(expectedMBs)M"]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + let actualInBytes = inspectResp.configuration.resources.memoryInBytes + #expect(actualInBytes == expectedMBs.mib(), "expected \(expectedMBs.mib()) bytes, instead got \(actualInBytes) bytes") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUlimitNofile() throws { + do { + let name = getTestName() + let softLimit = "1024" + let hardLimit = "2048" + try doLongRun(name: name, args: ["--ulimit", "nofile=\(softLimit):\(hardLimit)"]) + defer { + try? doStop(name: name) + } + + let inspectResp = try inspectContainer(name) + let rlimits = inspectResp.configuration.initProcess.rlimits + let nofileRlimit = rlimits.first { $0.limit == "RLIMIT_NOFILE" } + #expect(nofileRlimit != nil, "expected RLIMIT_NOFILE to be set") + #expect(nofileRlimit?.soft == UInt64(softLimit), "expected soft limit \(softLimit), got \(nofileRlimit?.soft ?? 0)") + #expect(nofileRlimit?.hard == UInt64(hardLimit), "expected hard limit \(hardLimit), got \(nofileRlimit?.hard ?? 0)") + + var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -n"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == softLimit, "expected ulimit -n to return \(softLimit), got \(output)") + + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUlimitNproc() throws { + do { + let name = getTestName() + let limit = "256" + try doLongRun(name: name, args: ["--ulimit", "nproc=\(limit)"]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + let rlimits = inspectResp.configuration.initProcess.rlimits + let nprocRlimit = rlimits.first { $0.limit == "RLIMIT_NPROC" } + #expect(nprocRlimit != nil, "expected RLIMIT_NPROC to be set") + #expect(nprocRlimit?.soft == UInt64(limit), "expected soft limit \(limit), got \(nprocRlimit?.soft ?? 0)") + #expect(nprocRlimit?.hard == UInt64(limit), "expected hard limit \(limit), got \(nprocRlimit?.hard ?? 0)") + + var output = try doExec(name: name, cmd: ["sh", "-c", "ulimit -u"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == limit, "expected ulimit -u to return \(limit), got \(output)") + + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandMultipleUlimits() throws { + do { + let name = getTestName() + try doLongRun( + name: name, + args: [ + "--ulimit", "nofile=1024:2048", + "--ulimit", "nproc=512", + "--ulimit", "stack=8388608", + ]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + let rlimits = inspectResp.configuration.initProcess.rlimits + #expect(rlimits.count == 3, "expected 3 rlimits, got \(rlimits.count)") + + let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" } + let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" } + let stack = rlimits.first { $0.limit == "RLIMIT_STACK" } + + #expect(nofile != nil && nofile?.soft == 1024 && nofile?.hard == 2048) + #expect(nproc != nil && nproc?.soft == 512 && nproc?.hard == 512) + #expect(stack != nil && stack?.soft == 8_388_608 && stack?.hard == 8_388_608) + + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } +} + +@Suite(.serialSuites) +class TestCLIRunCommand2: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } + + @Test func testRunCommandMount() throws { + do { + let name = getTestName() + let targetContainerPath = "/tmp/testmount" + let testData = "hello world" + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(testData.utf8)) else { + Issue.record("failed to create temporary file \(tempFile.path())") + return + } + defer { + try? FileManager.default.removeItem(at: tempDir) + } + try doLongRun(name: name, args: ["--mount", "type=virtiofs,source=\(tempDir.path()),target=\(targetContainerPath),readonly"]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(tempFile.lastPathComponent)"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUnixSocketMount() throws { + do { + let name = getTestName() + let socketPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + let socketType = try UnixType(path: socketPath.path, unlinkExisting: true) + let socket = try Socket(type: socketType, closeOnDeinit: true) + try socket.listen() + defer { + try? socket.close() + try? FileManager.default.removeItem(at: socketPath) + } + + try doLongRun( + name: name, + args: ["-v", "\(socketPath.path):/woo"] + ) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["ls", "-alh", "woo"]) + let splitOutput = output.components(separatedBy: .whitespaces) + #expect(splitOutput.count > 0, "expected split output of 'ls -alh' to be at least 1, instead got \(splitOutput.count)") + + let perms = splitOutput[0] + let firstChar = perms[perms.startIndex] + #expect(firstChar == "s", "expected file in guest to be of type socket, instead got '\(firstChar)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandTmpfs() throws { + do { + let name = getTestName() + let targetContainerPath = "/tmp/testtmpfs" + let expectedFilesystem = "tmpfs" + try doLongRun(name: name, args: ["--tmpfs", targetContainerPath]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["df", targetContainerPath]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2, "expected only two rows of output, instead got \(lines.count)") + let words = lines[1].split(separator: " ") + #expect(words.count > 1, "expected information to contain multiple words, got \(words.count)") + #expect(words[0].lowercased() == expectedFilesystem, "expected filesystem type to be \(expectedFilesystem), instead got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandShmSize() throws { + do { + let name = getTestName() + let shmSize = "128m" + let expectedKB = 128 * 1024 + try doLongRun(name: name, args: ["--shm-size", shmSize]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["mount"]) + let shmLine = output.split(separator: "\n").first { $0.contains("/dev/shm") } + #expect(shmLine != nil, "expected /dev/shm in mount output") + #expect(shmLine!.contains("size=\(expectedKB)k"), "expected size=\(expectedKB)k in mount options, got: \(shmLine!)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandOSArch() throws { + do { + let name = getLowercasedTestName() + let os = "linux" + let arch = "amd64" + let expectedArch = "x86_64" + try doLongRun(name: name, args: ["--os", os, "--arch", arch]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["uname", "-sm"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + #expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandPlatform() throws { + do { + let name = getTestName() + let os = "linux" + let platform = "linux/amd64" + let expectedArch = "x86_64" + try doLongRun(name: name, args: ["--platform", platform]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["uname", "-sm"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + #expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandVolume() throws { + do { + let name = getTestName() + let targetContainerPath = "/tmp/testvolume" + let testData = "one small step" + let volume = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: true) + let volumeFile = volume.appendingPathComponent(UUID().uuidString) + guard FileManager.default.createFile(atPath: volumeFile.path(), contents: Data(testData.utf8)) else { + Issue.record("failed to create file at \(volumeFile)") + return + } + defer { + try? FileManager.default.removeItem(at: volume) + } + try doLongRun(name: name, args: ["--volume", "\(volume.path):\(targetContainerPath)"]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(volumeFile.lastPathComponent)"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCidfile() throws { + do { + let name = getTestName() + let filePath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: filePath) + } + try doLongRun(name: name, args: ["--cidfile", filePath.path()]) + defer { + try? doStop(name: name) + } + let actualID = try String(contentsOf: filePath, encoding: .utf8) + #expect(actualID == name, "expected container ID '\(name)', instead got '\(actualID)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandNoDNS() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--no-dns"]) + defer { + try? doStop(name: name) + } + #expect(throws: (any Error).self) { + try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandInit() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--init"]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect(inspectResp.configuration.useInit == true, "expected useInit to be true in container configuration") + + // With --init, PID 1 should be the init process, not "sleep". + var output = try doExec(name: name, cmd: ["cat", "/proc/1/cmdline"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + !output.hasPrefix("sleep"), + "expected PID 1 to be init process, not 'sleep', got '\(output)'" + ) + try doStop(name: name) + } catch { + Issue.record("failed to run container with --init: \(error)") + return + } + } + + @Test func testRunCommandInitReapsZombies() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--init"]) + defer { + try? doStop(name: name) + } + + _ = try doExec( + name: name, + cmd: [ + "sh", "-c", + "sh -c 'sh -c \"exit 0\" &' && sleep 1", + ]) + + let psOutput = try doExec(name: name, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"]) + let zombieCount = Int(psOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1 + #expect( + zombieCount == 0, + "expected no zombie processes with --init, found \(zombieCount)" + ) + try doStop(name: name) + } catch { + Issue.record("failed to verify zombie reaping with --init: \(error)") + return + } + } + + @Test func testRunCommandWithoutInitDefault() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: []) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect(inspectResp.configuration.useInit == false, "expected useInit to be false by default") + try doStop(name: name) + } catch { + Issue.record("failed to run container without --init: \(error)") + return + } + } +} + +@Suite(.serialSuites) +class TestCLIRunCommand3: CLITest { + func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func getLowercasedTestName() -> String { + getTestName().lowercased() + } + + @Test func testRunCommandDefaultResolvConf() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: []) + defer { + try? doStop(name: name) + } + + let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + let actualLines = output.components(separatedBy: .newlines) + .filter { !$0.isEmpty } + .map { $0.components(separatedBy: .whitespaces) } + .map { $0.joined(separator: " ") } + + let inspectOutput = try inspectContainer(name) + let ip = try #require(inspectOutput.networks[0].ipv4Address).address + let expectedNameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description + let defaultDomain = try getDefaultDomain() + let expectedLines: [String] = [ + "nameserver \(expectedNameserver)", + defaultDomain.map { "domain \($0)" }, + ].compactMap { $0 } + + #expect(expectedLines == actualLines) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandNonDefaultResolvConf() throws { + do { + let expectedDns: String = "8.8.8.8" + let expectedDomain = "example.com" + let expectedSearch = "test.com" + let expectedOption = "debug" + let name = getTestName() + try doLongRun( + name: name, + args: [ + "--dns", expectedDns, + "--dns-domain", expectedDomain, + "--dns-search", expectedSearch, + "--dns-option", expectedOption, + ]) + defer { + try? doStop(name: name) + } + + let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + let actualLines = output.components(separatedBy: .newlines) + .filter { !$0.isEmpty } + .map { $0.components(separatedBy: .whitespaces) } + .map { $0.joined(separator: " ") } + + let expectedLines: [String] = [ + "nameserver \(expectedDns)", + "domain \(expectedDomain)", + "search \(expectedSearch)", + "options \(expectedOption)", + ] + #expect(expectedLines == actualLines) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunDefaultHostsEntries() throws { + do { + let name = getTestName() + try doLongRun(name: name) + defer { + try? doStop(name: name) + } + + let inspectOutput = try inspectContainer(name) + let ip = try #require(inspectOutput.networks[0].ipv4Address).address + + let output = try doExec(name: name, cmd: ["cat", "/etc/hosts"]) + let lines = output.split(separator: "\n") + + let expectedEntries = [("127.0.0.1", "localhost"), (ip.description, name)] + + for (i, line) in lines.enumerated() { + let words = line.split(separator: " ").map { String($0) } + #expect(words.count >= 2, "expected /etc/hosts entry to have 2 or more entries") + let expected = expectedEntries[i] + #expect(expected.0 == words[0], "expected /etc/hosts entries IP to be \(expected.0), instead got \(words[0])") + #expect(expected.1 == words[1], "expected /etc/hosts entries host to be \(expected.1), instead got \(words[1])") + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testForwardTCP() async throws { + let retries = 10 + let retryDelaySeconds = Int64(3) + do { + let name = getLowercasedTestName() + let proxyIp = "127.0.0.1" + let proxyPort = UInt16.random(in: 50000..<55000) + let serverPort = UInt16.random(in: 55000..<60000) + try doLongRun( + name: name, + image: "docker.io/library/python:alpine", + args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"]) + defer { + try? doStop(name: name) + } + + let url = "http://\(proxyIp):\(proxyPort)" + var request = HTTPClientRequest(url: url) + request.method = .GET + let config = HTTPClient.Configuration(proxy: nil) + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { _ = client.shutdown() } + var retriesRemaining = retries + var success = false + while !success && retriesRemaining > 0 { + do { + let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) + try #require(response.status == .ok) + success = true + print("request to \(url) succeeded") + } catch { + print("request to \(url) failed, error \(error)") + try await Task.sleep(for: .seconds(retryDelaySeconds)) + } + retriesRemaining -= 1 + } + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testForwardTCPPortRange() async throws { + let range = UInt16(10) + for portOffset in 0.. 0 { + do { + let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) + try #require(response.status == .ok) + success = true + print("request to \(url) succeeded") + } catch { + print("request to \(url) failed, error: \(error)") + try await Task.sleep(for: .seconds(retryDelaySeconds)) + } + retriesRemaining -= 1 + } + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + } + + @available(macOS 26, *) + @Test func testForwardTCPv6() async throws { + let retries = 10 + let retryDelaySeconds = Int64(3) + do { + let name = getLowercasedTestName() + let proxyIp = "[::1]" + let proxyPort = UInt16.random(in: 50000..<55000) + let serverPort = UInt16.random(in: 55000..<60000) + try doLongRun( + name: name, + image: "docker.io/library/node:alpine", + args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"], + containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"]) + defer { + try? doStop(name: name) + } + + let url = "http://\(proxyIp):\(proxyPort)" + var request = HTTPClientRequest(url: url) + request.method = .GET + let config = HTTPClient.Configuration(proxy: nil) + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { _ = client.shutdown() } + var retriesRemaining = retries + var success = false + while !success && retriesRemaining > 0 { + do { + let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) + try #require(response.status == .ok) + success = true + print("request to \(url) succeeded") + } catch { + print("request to \(url) failed, error \(error)") + try await Task.sleep(for: .seconds(retryDelaySeconds)) + } + retriesRemaining -= 1 + } + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnvFileFromNamedPipe() throws { + do { + let name = getTestName() + let pipePath = FileManager.default.temporaryDirectory.appendingPathComponent("envfile-pipe\(UUID().uuidString)") + + // create pipe + let result = mkfifo(pipePath.path(), 0o600) + guard result == 0 else { + Issue.record("failed to create named pipe: \(String(cString: strerror(errno)))") + return + } + + defer { + try? FileManager.default.removeItem(at: pipePath) + } + + let content = """ + FOO=bar + BAR=baz + """ + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: Data(content.utf8)) + try handle.close() + } catch { + Issue.record(error) + return + } + + group.leave() + } + + try doLongRun(name: name, args: ["--env-file", pipePath.path()]) + defer { + try? doStop(name: name) + } + + group.wait() + + let inspectResult = try inspectContainer(name) + let expected = [ + "FOO=bar", + "BAR=baz", + ] + + for item in expected { + #expect( + inspectResult.configuration.initProcess.environment.contains(item), + "expected environment variable \(item) not found" + ) + } + try doStop(name: name) + } catch { + Issue.record(error) + } + } + + @Test func testRunCommandReadOnly() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--read-only"]) + defer { + try? doStop(name: name) + } + // Attempt to touch a file on the read-only rootfs should fail + #expect(throws: (any Error).self) { + try doExec(name: name, cmd: ["touch", "/testfile"]) + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + func getDefaultDomain() throws -> String? { + let config = try getSystemConfig() + return config.dns.domain + } + + @Test func testPrivilegedPortError() throws { + try #require(geteuid() != 0) + + let name = getTestName() + let privilegedPort = 80 + let (_, _, error, status) = try run(arguments: [ + "run", + "--name", name, + "--publish", "127.0.0.1:\(privilegedPort):80", + alpine, + ]) + defer { + try? doRemove(name: name, force: true) + } + #expect(status != 0, "Command should have failed") + #expect( + error.contains("Permission denied while binding to host port \(privilegedPort)"), + "Error message should mention permission denied for the port. Got: \(error)" + ) + #expect( + error.contains("root privileges"), + "Error message should mention root privileges requirement. Got: \(error)" + ) + } +} diff --git a/Tests/ContainerResourceTests/AttachmentTest.swift b/Tests/ContainerResourceTests/AttachmentTest.swift new file mode 100644 index 000000000..60b7e50ef --- /dev/null +++ b/Tests/ContainerResourceTests/AttachmentTest.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Testing + +@testable import ContainerResource + +struct AttachmentTest { + @Test func testAttachmentNilIPFields() { + let attachment = Attachment( + network: "my-net", + hostname: "host1", + ipv4Address: nil, + ipv4Gateway: nil, + ipv6Address: nil, + macAddress: nil + ) + #expect(attachment.ipv4Address == nil) + #expect(attachment.ipv4Gateway == nil) + } +}