mirror of https://github.com/cirruslabs/tart.git
Merge 001cfca565 into f87b57bbc5
This commit is contained in:
commit
4d6e4f70bb
|
|
@ -250,6 +250,29 @@ struct Run: AsyncParsableCommand {
|
|||
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||
var netHost: Bool = false
|
||||
|
||||
#if compiler(>=6.4)
|
||||
@Flag(help: ArgumentHelp("Use native vmnet-backed networking instead of Softnet for port forwarding",
|
||||
discussion: """
|
||||
Adopts the VZVmnetNetworkDeviceAttachment API introduced in macOS 26.
|
||||
vmnet networks run in-process with no sidecar, so this can replace --net-softnet
|
||||
for the common "CI VM with a few forwarded ports" case.
|
||||
|
||||
Requires the host to be running macOS 26 (or newer).
|
||||
"""))
|
||||
var netVmnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Comma-separated list of ports to forward into the vmnet guest (e.g. --net-vmnet-expose 2222:22,8080:80/tcp,5353:53/udp)",
|
||||
discussion: """
|
||||
Each rule has the form EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] where PROTOCOL
|
||||
is tcp (default) or udp. EXTERNAL_PORT is bound on the host's egress interface
|
||||
and forwarded to INTERNAL_PORT on the guest.
|
||||
|
||||
Implies --net-vmnet.
|
||||
""",
|
||||
valueName: "comma-separated port specifications"))
|
||||
var netVmnetExpose: String?
|
||||
#endif
|
||||
|
||||
@Option(help: ArgumentHelp("Set the root disk options (e.g. --root-disk-opts=\"ro\" or --root-disk-opts=\"caching=cached,sync=none\")",
|
||||
discussion: """
|
||||
Options are comma-separated and are as follows:
|
||||
|
|
@ -329,11 +352,30 @@ struct Run: AsyncParsableCommand {
|
|||
if netBridged.count > 0 { netFlags += 1 }
|
||||
if netSoftnet { netFlags += 1 }
|
||||
if netHost { netFlags += 1 }
|
||||
#if compiler(>=6.4)
|
||||
// Automatically enable --net-vmnet when --net-vmnet-expose is specified
|
||||
if netVmnetExpose != nil {
|
||||
netVmnet = true
|
||||
}
|
||||
if netVmnet { netFlags += 1 }
|
||||
#endif
|
||||
|
||||
if netFlags > 1 {
|
||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
||||
#if compiler(>=6.4)
|
||||
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-vmnet are mutually exclusive")
|
||||
#else
|
||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
||||
#endif
|
||||
}
|
||||
|
||||
#if compiler(>=6.4)
|
||||
if netVmnet {
|
||||
if #unavailable(macOS 26) {
|
||||
throw ValidationError("--net-vmnet requires the host to be running macOS 26 (or newer)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
|
|
@ -697,6 +739,14 @@ struct Run: AsyncParsableCommand {
|
|||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
|
||||
}
|
||||
|
||||
#if compiler(>=6.4)
|
||||
if netVmnet, #available(macOS 26, *) {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let portForwardings = try netVmnetExpose.map { try NetworkVmnet.parsePortForwardings($0) } ?? []
|
||||
return try NetworkVmnet(vmMACAddress: config.macAddress.string, portForwardings: portForwardings)
|
||||
}
|
||||
#endif
|
||||
|
||||
if netBridged.count > 0 {
|
||||
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
// Native vmnet-backed networking, adopting the vmnet logical network API
|
||||
// surface introduced alongside VZVmnetNetworkDeviceAttachment.
|
||||
//
|
||||
// Goal: provide a sidecar-free alternative to the external Softnet process for
|
||||
// CI-style port forwarding from host TCP/UDP ports to guest ports. The vmnet
|
||||
// configuration is created in-process and handed to
|
||||
// VZVmnetNetworkDeviceAttachment, so the VM keeps using the standard
|
||||
// VZVirtioNetworkDeviceConfiguration path.
|
||||
//
|
||||
// The whole file is gated behind `#if compiler(>=6.4)` to match how Tart
|
||||
// already gates VZMacGuestProvisioningOptions in VM.swift (the same situation:
|
||||
// new SDK symbols referenced by a tree that still builds under Xcode 26).
|
||||
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
#if compiler(>=6.4)
|
||||
import vmnet
|
||||
|
||||
@available(macOS 26, *)
|
||||
class NetworkVmnet: Network {
|
||||
enum NetworkProtocol: String, CaseIterable {
|
||||
case tcp
|
||||
case udp
|
||||
}
|
||||
|
||||
struct PortForwarding: Equatable {
|
||||
let proto: NetworkProtocol
|
||||
let externalPort: UInt16
|
||||
let internalPort: UInt16
|
||||
}
|
||||
|
||||
private let network: vmnet_network_ref
|
||||
|
||||
init(vmMACAddress: String, portForwardings: [PortForwarding] = []) throws {
|
||||
let macAddress = try Self.parseMACAddress(vmMACAddress)
|
||||
let addressing = try Self.addressing(for: macAddress)
|
||||
|
||||
var configStatus: vmnet_return_t = .VMNET_FAILURE
|
||||
guard let configuration = vmnet_network_configuration_create(.VMNET_SHARED_MODE, &configStatus) else {
|
||||
throw NetworkVmnetError.ConfigurationCreationFailed(status: configStatus)
|
||||
}
|
||||
defer { Self.release(configuration) }
|
||||
|
||||
if !portForwardings.isEmpty {
|
||||
try Self.configureIPv4Addressing(addressing, for: macAddress, to: configuration)
|
||||
try Self.applyPortForwarding(rules: portForwardings, internalAddress: addressing.guestAddress, to: configuration)
|
||||
}
|
||||
|
||||
var networkStatus: vmnet_return_t = .VMNET_FAILURE
|
||||
guard let network = vmnet_network_create(configuration, &networkStatus) else {
|
||||
throw NetworkVmnetError.NetworkCreationFailed(status: networkStatus)
|
||||
}
|
||||
self.network = network
|
||||
}
|
||||
|
||||
deinit {
|
||||
Self.release(network)
|
||||
}
|
||||
|
||||
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||
[VZVmnetNetworkDeviceAttachment(network: network)]
|
||||
}
|
||||
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// vmnet networks run in-process. There is no sidecar to monitor.
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
// The network handle is released in deinit; the VM tears down the
|
||||
// attachment as part of its normal shutdown.
|
||||
}
|
||||
|
||||
private static func applyPortForwarding(
|
||||
rules: [PortForwarding],
|
||||
internalAddress: in_addr,
|
||||
to configuration: vmnet_network_configuration_ref
|
||||
) throws {
|
||||
for rule in rules {
|
||||
var internalAddress = internalAddress
|
||||
let status = withUnsafePointer(to: &internalAddress) {
|
||||
vmnet_network_configuration_add_port_forwarding_rule(
|
||||
configuration,
|
||||
rule.vmnetProtocol,
|
||||
sa_family_t(AF_INET),
|
||||
rule.internalPort,
|
||||
rule.externalPort,
|
||||
UnsafeRawPointer($0)
|
||||
)
|
||||
}
|
||||
|
||||
guard status == .VMNET_SUCCESS else {
|
||||
throw NetworkVmnetError.PortForwardingConfigurationFailed(rule: rule, status: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func configureIPv4Addressing(
|
||||
_ addressing: IPv4Addressing,
|
||||
for macAddress: MACAddress,
|
||||
to configuration: vmnet_network_configuration_ref
|
||||
) throws {
|
||||
var subnet = addressing.subnet
|
||||
var mask = addressing.mask
|
||||
let subnetStatus = withUnsafePointer(to: &subnet) { subnetPointer in
|
||||
withUnsafePointer(to: &mask) { maskPointer in
|
||||
vmnet_network_configuration_set_ipv4_subnet(configuration, subnetPointer, maskPointer)
|
||||
}
|
||||
}
|
||||
guard subnetStatus == .VMNET_SUCCESS else {
|
||||
throw NetworkVmnetError.IPv4SubnetConfigurationFailed(status: subnetStatus)
|
||||
}
|
||||
|
||||
var guestAddress = addressing.guestAddress
|
||||
var etherAddress = ether_addr_t(
|
||||
octet: (
|
||||
macAddress.mac[0],
|
||||
macAddress.mac[1],
|
||||
macAddress.mac[2],
|
||||
macAddress.mac[3],
|
||||
macAddress.mac[4],
|
||||
macAddress.mac[5]
|
||||
)
|
||||
)
|
||||
let reservationStatus = withUnsafePointer(to: ðerAddress) { macPointer in
|
||||
withUnsafePointer(to: &guestAddress) { guestAddressPointer in
|
||||
vmnet_network_configuration_add_dhcp_reservation(configuration, macPointer, guestAddressPointer)
|
||||
}
|
||||
}
|
||||
guard reservationStatus == .VMNET_SUCCESS else {
|
||||
throw NetworkVmnetError.DHCPReservationConfigurationFailed(status: reservationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
static func parsePortForwardings(_ spec: String) throws -> [PortForwarding] {
|
||||
try spec.split(separator: ",").map { try parseSingle(String($0)) }
|
||||
}
|
||||
|
||||
private static func parseSingle(_ raw: String) throws -> PortForwarding {
|
||||
let (portPart, protoPart): (String, String) = {
|
||||
if let slashIdx = raw.firstIndex(of: "/") {
|
||||
return (String(raw[..<slashIdx]), String(raw[raw.index(after: slashIdx)...]))
|
||||
}
|
||||
return (raw, "tcp")
|
||||
}()
|
||||
|
||||
let ports = portPart.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard ports.count == 2,
|
||||
let external = UInt16(ports[0]),
|
||||
let internalPort = UInt16(ports[1]),
|
||||
external > 0, internalPort > 0
|
||||
else {
|
||||
throw NetworkVmnetError.InvalidPortForwardingSpec(
|
||||
spec: raw,
|
||||
why: "expected EXTERNAL_PORT:INTERNAL_PORT[/PROTOCOL] with non-zero ports"
|
||||
)
|
||||
}
|
||||
|
||||
guard let proto = NetworkProtocol(rawValue: protoPart.lowercased()) else {
|
||||
throw NetworkVmnetError.InvalidPortForwardingSpec(
|
||||
spec: raw,
|
||||
why: "unknown protocol \"\(protoPart)\", expected tcp or udp"
|
||||
)
|
||||
}
|
||||
|
||||
return PortForwarding(proto: proto, externalPort: external, internalPort: internalPort)
|
||||
}
|
||||
|
||||
private struct IPv4Addressing {
|
||||
let subnet: in_addr
|
||||
let mask: in_addr
|
||||
let guestAddress: in_addr
|
||||
}
|
||||
|
||||
private static func addressing(for macAddress: MACAddress) throws -> IPv4Addressing {
|
||||
let thirdOctet = subnetThirdOctet(for: macAddress)
|
||||
|
||||
return try IPv4Addressing(
|
||||
subnet: ipv4Address("192.168.\(thirdOctet).0"),
|
||||
mask: ipv4Address("255.255.255.0"),
|
||||
guestAddress: ipv4Address("192.168.\(thirdOctet).2")
|
||||
)
|
||||
}
|
||||
|
||||
private static func subnetThirdOctet(for macAddress: MACAddress) -> UInt8 {
|
||||
var hash: UInt32 = 2_166_136_261
|
||||
for byte in macAddress.mac {
|
||||
hash ^= UInt32(byte)
|
||||
hash &*= 16_777_619
|
||||
}
|
||||
|
||||
// Keep forwarded VMs on stable per-MAC subnets and avoid bridge100's common default.
|
||||
var octet = UInt8((hash % 253) + 2)
|
||||
if octet == 64 {
|
||||
octet = 65
|
||||
}
|
||||
return octet
|
||||
}
|
||||
|
||||
private static func ipv4Address(_ string: String) throws -> in_addr {
|
||||
var address = in_addr()
|
||||
guard inet_pton(AF_INET, string, &address) == 1 else {
|
||||
throw NetworkVmnetError.InvalidIPv4Address(string)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
private static func parseMACAddress(_ string: String) throws -> MACAddress {
|
||||
guard let macAddress = MACAddress(fromString: string) else {
|
||||
throw NetworkVmnetError.InvalidMACAddress(string)
|
||||
}
|
||||
return macAddress
|
||||
}
|
||||
|
||||
private static func release(_ pointer: OpaquePointer) {
|
||||
Unmanaged<AnyObject>.fromOpaque(UnsafeRawPointer(pointer)).release()
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26, *)
|
||||
extension NetworkVmnet.PortForwarding: CustomStringConvertible {
|
||||
fileprivate var vmnetProtocol: UInt8 {
|
||||
switch proto {
|
||||
case .tcp:
|
||||
return UInt8(IPPROTO_TCP)
|
||||
case .udp:
|
||||
return UInt8(IPPROTO_UDP)
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(externalPort):\(internalPort)/\(proto.rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26, *)
|
||||
enum NetworkVmnetError: Error, CustomStringConvertible {
|
||||
case ConfigurationCreationFailed(status: vmnet_return_t)
|
||||
case NetworkCreationFailed(status: vmnet_return_t)
|
||||
case IPv4SubnetConfigurationFailed(status: vmnet_return_t)
|
||||
case DHCPReservationConfigurationFailed(status: vmnet_return_t)
|
||||
case PortForwardingConfigurationFailed(rule: NetworkVmnet.PortForwarding, status: vmnet_return_t)
|
||||
case InvalidPortForwardingSpec(spec: String, why: String)
|
||||
case InvalidIPv4Address(String)
|
||||
case InvalidMACAddress(String)
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .ConfigurationCreationFailed(let status):
|
||||
return "vmnet_network_configuration_create() failed with status \(status)"
|
||||
case .NetworkCreationFailed(let status):
|
||||
return "vmnet_network_create() failed with status \(status)"
|
||||
case .InvalidPortForwardingSpec(let spec, let why):
|
||||
return "invalid port forwarding spec \"\(spec)\": \(why)"
|
||||
case .IPv4SubnetConfigurationFailed(let status):
|
||||
return "vmnet_network_configuration_set_ipv4_subnet() failed with status \(status)"
|
||||
case .DHCPReservationConfigurationFailed(let status):
|
||||
return "vmnet_network_configuration_add_dhcp_reservation() failed with status \(status)"
|
||||
case .PortForwardingConfigurationFailed(let rule, let status):
|
||||
return "vmnet_network_configuration_add_port_forwarding_rule(\(rule)) failed with status \(status)"
|
||||
case .InvalidIPv4Address(let address):
|
||||
return "invalid IPv4 address \"\(address)\""
|
||||
case .InvalidMACAddress(let macAddress):
|
||||
return "invalid MAC address \"\(macAddress)\""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -66,6 +66,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
label: name,
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
|
|
@ -197,7 +198,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
vmConfig: config, label: name, network: network,
|
||||
additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
|
|
@ -315,6 +316,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||
diskURL: URL,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
label: String? = nil,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
|
|
@ -444,11 +446,26 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||
// Socket device
|
||||
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||
|
||||
#if compiler(>=6.4)
|
||||
if #available(macOS 27, *), let label, let virtualMachineLabel = Self.virtualMachineLabel(for: label) {
|
||||
configuration.label = virtualMachineLabel
|
||||
}
|
||||
#endif
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
return configuration
|
||||
}
|
||||
|
||||
static func virtualMachineLabel(for name: String) -> String? {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmedName.contains(where: { !$0.isWhitespace }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(trimmedName.prefix(64))
|
||||
}
|
||||
|
||||
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
||||
print("guest has stopped the virtual machine")
|
||||
sema.signal()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
#if compiler(>=6.4)
|
||||
@available(macOS 26, *)
|
||||
final class NetworkVmnetTests: XCTestCase {
|
||||
func testParsesSingleTCPRule() throws {
|
||||
let rules = try NetworkVmnet.parsePortForwardings("2222:22")
|
||||
XCTAssertEqual(rules, [
|
||||
NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 2222, internalPort: 22),
|
||||
])
|
||||
}
|
||||
|
||||
func testParsesExplicitProtocols() throws {
|
||||
let rules = try NetworkVmnet.parsePortForwardings("8080:80/tcp,5353:53/udp")
|
||||
XCTAssertEqual(rules, [
|
||||
NetworkVmnet.PortForwarding(proto: .tcp, externalPort: 8080, internalPort: 80),
|
||||
NetworkVmnet.PortForwarding(proto: .udp, externalPort: 5353, internalPort: 53),
|
||||
])
|
||||
}
|
||||
|
||||
func testProtocolIsCaseInsensitive() throws {
|
||||
let rules = try NetworkVmnet.parsePortForwardings("9000:9000/UDP")
|
||||
XCTAssertEqual(rules.first?.proto, .udp)
|
||||
}
|
||||
|
||||
func testRejectsMissingInternalPort() {
|
||||
XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222"))
|
||||
}
|
||||
|
||||
func testRejectsZeroPort() {
|
||||
XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("0:22"))
|
||||
XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:0"))
|
||||
}
|
||||
|
||||
func testRejectsUnknownProtocol() {
|
||||
XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("2222:22/sctp"))
|
||||
}
|
||||
|
||||
func testRejectsOutOfRangePort() {
|
||||
XCTAssertThrowsError(try NetworkVmnet.parsePortForwardings("99999:22"))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class VMTests: XCTestCase {
|
||||
func testVirtualMachineLabelUsesVMName() {
|
||||
XCTAssertEqual(VM.virtualMachineLabel(for: "macos-runner"), "macos-runner")
|
||||
}
|
||||
|
||||
func testVirtualMachineLabelTrimsWhitespace() {
|
||||
XCTAssertEqual(VM.virtualMachineLabel(for: " macos-runner "), "macos-runner")
|
||||
}
|
||||
|
||||
func testVirtualMachineLabelRejectsWhitespaceOnlyName() {
|
||||
XCTAssertNil(VM.virtualMachineLabel(for: " "))
|
||||
}
|
||||
|
||||
func testVirtualMachineLabelCapsAtSixtyFourCharacters() {
|
||||
XCTAssertEqual(
|
||||
VM.virtualMachineLabel(for: String(repeating: "a", count: 65)),
|
||||
String(repeating: "a", count: 64)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue