mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 18:05:25 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9df6f800a | ||
|
|
8955b9f946 | ||
|
|
ea453cb111 | ||
|
|
3207aa6e47 | ||
|
|
e8e97444af | ||
|
|
543453404c | ||
|
|
bb5cb01d31 | ||
|
|
6c31a73166 | ||
|
|
e2042dedcd | ||
|
|
12501370cc | ||
|
|
7d39f4cf68 | ||
|
|
8364f4ccac | ||
|
|
ef925e433d | ||
|
|
37af26a810 | ||
|
|
05edeac562 | ||
|
|
917c0b51ed | ||
|
|
e3b4f71f77 | ||
|
|
e92c3b900f | ||
|
|
5c1c6bd315 | ||
|
|
27d3e2c5da | ||
|
|
a80ec74a42 | ||
|
|
8ac52501c3 |
@@ -5,12 +5,15 @@ set -eu
|
|||||||
ARCH="$1"
|
ARCH="$1"
|
||||||
SCRATCH_PATH=".build/$ARCH"
|
SCRATCH_PATH=".build/$ARCH"
|
||||||
OUTPUT_PATH=".build/prebuilt/$ARCH"
|
OUTPUT_PATH=".build/prebuilt/$ARCH"
|
||||||
|
SWIFT_COMPATIBILITY_LIBRARY="$(dirname "$(xcrun --find swiftc)")/../lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib"
|
||||||
|
|
||||||
swift build \
|
swift build \
|
||||||
--build-system swiftbuild \
|
--build-system swiftbuild \
|
||||||
--scratch-path "$SCRATCH_PATH" \
|
--scratch-path "$SCRATCH_PATH" \
|
||||||
--arch "$ARCH" \
|
--arch "$ARCH" \
|
||||||
--configuration release \
|
--configuration release \
|
||||||
|
-Xlinker -weak_library \
|
||||||
|
-Xlinker "$SWIFT_COMPATIBILITY_LIBRARY" \
|
||||||
--product tart
|
--product tart
|
||||||
|
|
||||||
BIN_PATH=$(swift build \
|
BIN_PATH=$(swift build \
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
|
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
|
||||||
var stacked: Bool = false
|
var stacked: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "overwrite an existing local VM")
|
||||||
|
var overwrite: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
||||||
var pruneLimit: UInt = 100
|
var pruneLimit: UInt = 100
|
||||||
|
|
||||||
@@ -52,6 +55,8 @@ struct Clone: AsyncParsableCommand {
|
|||||||
let localStorage = try VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
let remoteName = try? RemoteName(sourceName)
|
let remoteName = try? RemoteName(sourceName)
|
||||||
|
|
||||||
|
try rejectExistingDestination(localStorage)
|
||||||
|
|
||||||
if stacked {
|
if stacked {
|
||||||
guard remoteName != nil else {
|
guard remoteName != nil else {
|
||||||
throw ValidationError("--stacked requires a remote image")
|
throw ValidationError("--stacked requires a remote image")
|
||||||
@@ -98,7 +103,11 @@ struct Clone: AsyncParsableCommand {
|
|||||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
|
|
||||||
|
try rejectExistingDestination(localStorage)
|
||||||
|
|
||||||
let sourceState = try sourceVM.state()
|
let sourceState = try sourceVM.state()
|
||||||
|
let sourceConfig = try VMConfig(fromURL: sourceVM.configURL)
|
||||||
|
|
||||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||||
&& sourceState != .Suspended
|
&& sourceState != .Suspended
|
||||||
|
|
||||||
@@ -121,6 +130,10 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sourceState != .Suspended, sourceConfig.platform is Linux {
|
||||||
|
try tmpVMDir.regenerateLinuxMachineIdentifier()
|
||||||
|
}
|
||||||
|
|
||||||
try localStorage.move(newName, from: tmpVMDir)
|
try localStorage.move(newName, from: tmpVMDir)
|
||||||
|
|
||||||
try lock.unlock()
|
try lock.unlock()
|
||||||
@@ -151,4 +164,11 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try? tmpVMDir.removeFromDisk()
|
try? tmpVMDir.removeFromDisk()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func rejectExistingDestination(_ localStorage: VMStorageLocal) throws {
|
||||||
|
let destinationURL = localStorage.baseURL.appendingPathComponent(newName, isDirectory: true)
|
||||||
|
if !overwrite && FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||||
|
throw ValidationError("VM \"\(newName)\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
let DiskFormat: String
|
let DiskFormat: String
|
||||||
let Size: HumanReadableByteCount
|
let Size: HumanReadableByteCount
|
||||||
let Display: String
|
let Display: String
|
||||||
|
let MACAddress: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ struct Get: AsyncParsableCommand {
|
|||||||
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
||||||
},
|
},
|
||||||
Display: vmConfig.display.description,
|
Display: vmConfig.display.description,
|
||||||
|
MACAddress: vmConfig.macAddress.string,
|
||||||
Running: try vmDir.running(),
|
Running: try vmDir.running(),
|
||||||
State: try vmDir.state().rawValue
|
State: try vmDir.state().rawValue
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
|
|
||||||
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n
|
By default, Tart is using a "dhcp" resolver which parses the DHCP reservation file, then the lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs not using the bridged networking.\n
|
||||||
Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
|
Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
|
||||||
A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
|
A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
|
||||||
"""))
|
"""))
|
||||||
@@ -60,6 +60,9 @@ struct IP: AsyncParsableCommand {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
case .dhcp:
|
case .dhcp:
|
||||||
|
if let bootptab = try Bootptab(), let ip = try bootptab.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,31 +38,47 @@ struct List: AsyncParsableCommand {
|
|||||||
var infos: [VMInfo] = []
|
var infos: [VMInfo] = []
|
||||||
|
|
||||||
if source == nil || source == "local" {
|
if source == nil || source == "local" {
|
||||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
infos += sortedInfos(try VMStorageLocal().list().compactMap { (name, vmDir) -> VMInfo? in
|
||||||
try VMInfo(
|
do {
|
||||||
Source: "local",
|
return try VMInfo(
|
||||||
Name: name,
|
Source: "local",
|
||||||
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
Name: name,
|
||||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Running: vmDir.running(),
|
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||||
State: vmDir.state().rawValue
|
Running: vmDir.running(),
|
||||||
)
|
State: vmDir.state().rawValue
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// A VM can disappear after storage enumeration when another process deletes it.
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == nil || source == "oci" {
|
if source == nil || source == "oci" {
|
||||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
infos += sortedInfos(try VMStorageOCI().list().compactMap { (name, vmDir, _) -> VMInfo? in
|
||||||
try VMInfo(
|
do {
|
||||||
Source: "OCI",
|
return try VMInfo(
|
||||||
Name: name,
|
Source: "OCI",
|
||||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Name: name,
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Running: vmDir.running(),
|
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||||
State: vmDir.state().rawValue
|
Running: vmDir.running(),
|
||||||
)
|
State: vmDir.state().rawValue
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// A VM can disappear after storage enumeration when another process deletes it.
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ struct Run: AsyncParsableCommand {
|
|||||||
""", valueName: "interface name"))
|
""", valueName: "interface name"))
|
||||||
var netBridged: [String] = []
|
var netBridged: [String] = []
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Use a connected datagram Unix domain socket inherited from the parent process for networking (e.g. --net-fd=3)",
|
||||||
|
discussion: """
|
||||||
|
The socket carries raw Ethernet frames, one per datagram.
|
||||||
|
|
||||||
|
The simplest use-case is composing Tart and Softnet invocations that are orchestrated by another program, such as Orchard.
|
||||||
|
|
||||||
|
Another way to use this argument is to connect a VM to any other networking implementation such as gVisor or software-defined networking implementation.
|
||||||
|
""",
|
||||||
|
valueName: "file descriptor"))
|
||||||
|
var netFd: Int32?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
||||||
discussion: """
|
discussion: """
|
||||||
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
||||||
@@ -274,7 +285,9 @@ struct Run: AsyncParsableCommand {
|
|||||||
var rootDiskOpts: String = ""
|
var rootDiskOpts: String = ""
|
||||||
|
|
||||||
#if arch(arm64)
|
#if arch(arm64)
|
||||||
@Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
@Flag(
|
||||||
|
help: ArgumentHelp("Disables or replaces devices that do not support VM suspension, such as audio, entropy and some input devices.",
|
||||||
|
discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
|
||||||
#endif
|
#endif
|
||||||
var suspendable: Bool = false
|
var suspendable: Bool = false
|
||||||
|
|
||||||
@@ -332,9 +345,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
if netBridged.count > 0 { netFlags += 1 }
|
if netBridged.count > 0 { netFlags += 1 }
|
||||||
if netSoftnet { netFlags += 1 }
|
if netSoftnet { netFlags += 1 }
|
||||||
if netHost { netFlags += 1 }
|
if netHost { netFlags += 1 }
|
||||||
|
if netFd != nil { netFlags += 1 }
|
||||||
|
|
||||||
if netFlags > 1 {
|
if netFlags > 1 {
|
||||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
if graphics && noGraphics {
|
if graphics && noGraphics {
|
||||||
@@ -342,7 +356,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||||
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
throw ValidationError("--capture-system-keys can only be used with the default VM view")
|
||||||
}
|
}
|
||||||
|
|
||||||
if nested {
|
if nested {
|
||||||
@@ -362,7 +376,11 @@ struct Run: AsyncParsableCommand {
|
|||||||
if suspendable {
|
if suspendable {
|
||||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
if !(config.platform is PlatformSuspendable) {
|
if !(config.platform is PlatformSuspendable) {
|
||||||
throw ValidationError("You can only suspend macOS VMs")
|
throw ValidationError("This platform is not suspendable")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let linux = config.platform as? Linux, linux.machineIdentifier == nil {
|
||||||
|
throw ValidationError("Linux VMs without a machine identifier cannot be suspended or resumed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if noTrackpad {
|
if noTrackpad {
|
||||||
@@ -409,14 +427,15 @@ struct Run: AsyncParsableCommand {
|
|||||||
let localStorage = try VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
|
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
|
try storageLock.lock()
|
||||||
|
|
||||||
// Validate disk format support
|
// Validate disk format support
|
||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
if !vmConfig.diskFormat.isSupported {
|
if !vmConfig.diskFormat.isSupported {
|
||||||
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
|
||||||
try storageLock.lock()
|
|
||||||
// check if there is a running VM with the same MAC address
|
// check if there is a running VM with the same MAC address
|
||||||
let hasRunningMACCollision = try localStorage.list().contains {
|
let hasRunningMACCollision = try localStorage.list().contains {
|
||||||
// check if there is a running VM with the same MAC but different name
|
// check if there is a running VM with the same MAC but different name
|
||||||
@@ -428,7 +447,24 @@ struct Run: AsyncParsableCommand {
|
|||||||
try vmDir.regenerateMACAddress()
|
try vmDir.regenerateMACAddress()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
// check if there is a running VM with the same Linux machine identifier but different name
|
||||||
|
if let linuxMachineIdentifier = (vmConfig.platform as? Linux)?.machineIdentifier {
|
||||||
|
let hasRunningLinuxMachineIdentifierCollision = try localStorage.list().contains {
|
||||||
|
if try !$1.running() || $1.name == vmDir.name {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let otherConfig = try VMConfig(fromURL: $1.configURL)
|
||||||
|
return (otherConfig.platform as? Linux)?.machineIdentifier == linuxMachineIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasRunningLinuxMachineIdentifierCollision {
|
||||||
|
print("There is already a running VM with the same Linux machine identifier!")
|
||||||
|
print("Resetting VM to assign a new Linux machine identifier...")
|
||||||
|
try vmDir.regenerateLinuxMachineIdentifier()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if netSoftnet && isInteractiveSession() {
|
||||||
try Softnet.configureSUIDBitIfNeeded()
|
try Softnet.configureSUIDBitIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,9 +739,11 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if netHost {
|
if netHost {
|
||||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
guard #available(macOS 26, *) else {
|
||||||
|
throw ValidationError("--net-host requires macOS 26 (Tahoe) or newer")
|
||||||
|
}
|
||||||
|
|
||||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
|
return try NetworkHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
if netBridged.count > 0 {
|
if netBridged.count > 0 {
|
||||||
@@ -723,6 +761,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let netFd = netFd {
|
||||||
|
return try NetworkFD(fd: netFd)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
||||||
var displayRefit: Bool? = nil
|
var displayRefit: Bool? = nil
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("MAC address to use."))
|
||||||
|
var mac: String?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||||
var randomMAC: Bool = false
|
var randomMAC: Bool = false
|
||||||
|
|
||||||
@@ -47,6 +50,10 @@ struct Set: AsyncParsableCommand {
|
|||||||
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if randomMAC && mac != nil {
|
||||||
|
throw ValidationError("--random-mac and --mac are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
if let cpu = cpu {
|
if let cpu = cpu {
|
||||||
@@ -67,7 +74,17 @@ struct Set: AsyncParsableCommand {
|
|||||||
vmConfig.display.unit = display.unit
|
vmConfig.display.unit = display.unit
|
||||||
}
|
}
|
||||||
|
|
||||||
vmConfig.displayRefit = displayRefit
|
if let displayRefit = displayRefit {
|
||||||
|
vmConfig.displayRefit = displayRefit
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mac = mac {
|
||||||
|
if let macAddress = VZMACAddress.init(string: mac), macAddress.isUnicastAddress {
|
||||||
|
vmConfig.macAddress = macAddress
|
||||||
|
} else {
|
||||||
|
throw ValidationError("'\(mac)' is not a valid unicast MAC address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if randomMAC {
|
if randomMAC {
|
||||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
@@ -84,7 +101,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
if let disk = disk {
|
if let disk = disk {
|
||||||
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
|
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
|
||||||
|
|
||||||
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path())
|
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path(percentEncoded: false))
|
||||||
|
|
||||||
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
|
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class ControlSocket {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||||
|
.serverChannelInitializer { channel in
|
||||||
|
channel.pipeline.addHandler(
|
||||||
|
ControlSocketAcceptErrorHandler(),
|
||||||
|
name: "ControlSocketAcceptErrorHandler"
|
||||||
|
)
|
||||||
|
}
|
||||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||||
childChannel.eventLoop.makeCompletedFuture {
|
childChannel.eventLoop.makeCompletedFuture {
|
||||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||||
@@ -125,3 +131,16 @@ class ControlSocket {
|
|||||||
return fd
|
return fd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ControlSocketAcceptErrorHandler: ChannelInboundHandler {
|
||||||
|
typealias InboundIn = Channel
|
||||||
|
typealias InboundOut = Channel
|
||||||
|
|
||||||
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||||
|
if error is NIOFcntlFailedError {
|
||||||
|
context.channel.read()
|
||||||
|
} else {
|
||||||
|
context.fireErrorCaught(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ struct DockerAuthConfig: Codable {
|
|||||||
guard let data = Data(base64Encoded: authBase64) else {
|
guard let data = Data(base64Encoded: authBase64) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
guard let components = String(data: data, encoding: .utf8)?.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if components.count != 2 {
|
if components.count != 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (components[0], components[1])
|
return (String(components[0]), String(components[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ func withGuestAgentChannel<T>(
|
|||||||
let channel = try GRPCChannelPool.with(
|
let channel = try GRPCChannelPool.with(
|
||||||
target: .unixDomainSocket(socketPath),
|
target: .unixDomainSocket(socketPath),
|
||||||
transportSecurity: .plaintext,
|
transportSecurity: .plaintext,
|
||||||
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
|
eventLoopGroup: .singletonMultiThreadedEventLoopGroup
|
||||||
)
|
) { configuration in
|
||||||
|
// Limit guest output in flight to work around VirtIO socket device implementation data loss
|
||||||
|
configuration.http2.targetWindowSize = 64 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await body(channel)
|
let result = try await body(channel)
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
|
|||||||
struct ARPCache {
|
struct ARPCache {
|
||||||
let arpCommandOutput: Data
|
let arpCommandOutput: Data
|
||||||
|
|
||||||
init() throws {
|
init(arpCommandURL: URL = URL(fileURLWithPath: "/usr/sbin/arp")) throws {
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
process.executableURL = arpCommandURL
|
||||||
process.arguments = ["-an"]
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
@@ -53,9 +53,7 @@ struct ARPCache {
|
|||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() ?? Data()
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
|
||||||
}
|
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
@@ -69,9 +67,12 @@ struct ARPCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
let output = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.components(separatedBy: "\n")
|
if output.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let lines = output.components(separatedBy: "\n")
|
||||||
|
|
||||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
struct Bootptab {
|
||||||
|
private var reservations: [MACAddress: Swift.Set<IPv4Address>] = [:]
|
||||||
|
|
||||||
|
init?(_ fromURL: URL = URL(fileURLWithPath: "/etc/bootptab")) throws {
|
||||||
|
let contents: String
|
||||||
|
|
||||||
|
do {
|
||||||
|
contents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in contents.split(whereSeparator: \.isNewline) {
|
||||||
|
// Skip comment lines
|
||||||
|
guard !line.hasPrefix("#") else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = line.split(whereSeparator: \.isWhitespace)
|
||||||
|
|
||||||
|
// Skip lines that don't look like reservation fields
|
||||||
|
guard fields.count >= 4 else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign reservation fields
|
||||||
|
let hardwareType = fields[1]
|
||||||
|
let hardwareAddress = fields[2]
|
||||||
|
let ipAddress = fields[3]
|
||||||
|
|
||||||
|
// Skip non-Ethernet reservations
|
||||||
|
guard hardwareType == "1" else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip malformed MAC addresses
|
||||||
|
guard let mac = MACAddress(fromString: String(hardwareAddress)) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip malformed IPv4 addresses
|
||||||
|
guard let ip = IPv4Address(String(ipAddress)) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
reservations[mac, default: []].insert(ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||||
|
guard let addresses = reservations[macAddress] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if addresses.count > 1 {
|
||||||
|
let addresses = addresses.map { $0.debugDescription }.sorted().joined(separator: ", ")
|
||||||
|
|
||||||
|
throw RuntimeError.Generic("multiple DHCP reservations in /etc/bootptab for \(macAddress): \(addresses)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return addresses.first
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,11 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (index, component) in components.enumerated() {
|
for (index, component) in components.enumerated() {
|
||||||
mac[index] = UInt8(component, radix: 16)!
|
guard let byte = UInt8(component, radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mac[index] = byte
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkFD: Network {
|
||||||
|
private let attachment: VZFileHandleNetworkDeviceAttachment
|
||||||
|
|
||||||
|
init(fd: Int32) throws {
|
||||||
|
do {
|
||||||
|
try Softnet.validateControlFD(fd, type: SOCK_DGRAM)
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("--net-fd \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = fcntl(fd, F_GETFD)
|
||||||
|
guard flags != -1 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else {
|
||||||
|
throw RuntimeError.Generic("failed to set close-on-exec on --net-fd: \(Errno(rawValue: errno))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fileHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[attachment]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
|
import Virtualization
|
||||||
|
import vmnet
|
||||||
|
|
||||||
|
@available(macOS 26, *)
|
||||||
|
class NetworkHost: Network {
|
||||||
|
private let attachment: VZNetworkDeviceAttachment
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
var status = vmnet_return_t.VMNET_SUCCESS
|
||||||
|
guard let configuration = vmnet_network_configuration_create(.VMNET_HOST_MODE, &status) else {
|
||||||
|
throw RuntimeError.Generic("Failed to create a vmnet configuration for host-only networking: \(status)")
|
||||||
|
}
|
||||||
|
defer { Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(configuration)).release() }
|
||||||
|
|
||||||
|
guard let network = vmnet_network_create(configuration, &status) else {
|
||||||
|
var message = "Failed to create a vmnet network for host-only networking: \(status)"
|
||||||
|
|
||||||
|
if status == .VMNET_NOT_AUTHORIZED {
|
||||||
|
message += ". Creating a vmnet network requires root privileges or a properly signed app with the com.apple.vm.networking entitlement."
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.Generic(message)
|
||||||
|
}
|
||||||
|
defer { Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(network)).release() }
|
||||||
|
|
||||||
|
attachment = VZVmnetNetworkDeviceAttachment(network: network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[attachment]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension vmnet_return_t: @retroactive CustomStringConvertible {
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .VMNET_SUCCESS: return "successfully completed"
|
||||||
|
case .VMNET_FAILURE: return "general failure"
|
||||||
|
case .VMNET_MEM_FAILURE: return "memory allocation failure"
|
||||||
|
case .VMNET_INVALID_ARGUMENT: return "invalid argument specified"
|
||||||
|
case .VMNET_SETUP_INCOMPLETE: return "interface setup is not complete"
|
||||||
|
case .VMNET_INVALID_ACCESS: return "permission denied"
|
||||||
|
case .VMNET_PACKET_TOO_BIG: return "packet size larger than MTU"
|
||||||
|
case .VMNET_BUFFER_EXHAUSTED: return "buffers exhausted in kernel"
|
||||||
|
case .VMNET_TOO_MANY_PACKETS: return "packet count exceeds limit"
|
||||||
|
case .VMNET_SHARING_SERVICE_BUSY: return "vmnet interface cannot be started as conflicting sharing service is in use"
|
||||||
|
case .VMNET_NOT_AUTHORIZED: return "the operation could not be completed due to missing authorization"
|
||||||
|
@unknown default: return "unknown vmnet status (\(rawValue))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
|
|
||||||
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
||||||
try Self.validateControlFD(controlFD)
|
do {
|
||||||
|
try Self.validateControlFD(controlFD)
|
||||||
|
} catch {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
@@ -50,20 +54,16 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func validateControlFD(_ fd: Int32) throws {
|
static func validateControlFD(_ fd: Int32, type: Int32 = SOCK_STREAM) throws {
|
||||||
guard fd > STDERR_FILENO else {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
var socketType: Int32 = 0
|
var socketType: Int32 = 0
|
||||||
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
||||||
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
|
throw RuntimeError.Generic("is not a socket: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard socketType == SOCK_STREAM else {
|
guard socketType == type else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a \(type == SOCK_STREAM ? "stream" : "datagram") socket")
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerAddress = sockaddr_storage()
|
var peerAddress = sockaddr_storage()
|
||||||
@@ -75,11 +75,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
guard result == 0 else {
|
guard result == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
|
throw RuntimeError.Generic("is not connected: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a Unix socket")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,11 +226,26 @@ struct OCIConfig: Codable {
|
|||||||
var architecture: Architecture = .arm64
|
var architecture: Architecture = .arm64
|
||||||
var os: OS = .darwin
|
var os: OS = .darwin
|
||||||
var config: ConfigContainer?
|
var config: ConfigContainer?
|
||||||
|
// Required by the OCI image spec and enforced by registries that validate
|
||||||
|
// image configs (e.g. Quay). Optional so that configs pushed by older Tart
|
||||||
|
// versions, which lack it, can still be decoded.
|
||||||
|
var rootfs: RootFS? = RootFS()
|
||||||
|
|
||||||
struct ConfigContainer: Codable {
|
struct ConfigContainer: Codable {
|
||||||
var Labels: [String: String]?
|
var Labels: [String: String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RootFS: Codable {
|
||||||
|
var type: String = "layers"
|
||||||
|
// Tart layers are not filesystem tarballs, so there are no DiffIDs to list
|
||||||
|
var diffIDs: [String] = []
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case diffIDs = "diff_ids"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toJSON() throws -> Data {
|
func toJSON() throws -> Data {
|
||||||
try Config.jsonEncoder().encode(self)
|
try Config.jsonEncoder().encode(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
let dateFormatter = ISO8601DateFormatter()
|
let dateFormatter = ISO8601DateFormatter()
|
||||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
let fractionalDateFormatter = ISO8601DateFormatter()
|
||||||
|
fractionalDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
fractionalDateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .custom { decoder in
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let dateString = try container.decode(String.self)
|
let dateString = try container.decode(String.self)
|
||||||
|
|
||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? fractionalDateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
@@ -357,12 +360,9 @@ class Registry {
|
|||||||
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
Self.updateQueryItems(parameters.map { key, value in
|
||||||
urlComponents.queryItems = []
|
|
||||||
}
|
|
||||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
|
||||||
URLQueryItem(name: key, value: value)
|
URLQueryItem(name: key, value: value)
|
||||||
})
|
}, in: &urlComponents)
|
||||||
|
|
||||||
var request = URLRequest(url: urlComponents.url!)
|
var request = URLRequest(url: urlComponents.url!)
|
||||||
request.httpMethod = method.rawValue
|
request.httpMethod = method.rawValue
|
||||||
@@ -384,6 +384,30 @@ class Registry {
|
|||||||
return (channel, response)
|
return (channel, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserve the exact encoding of existing query values, even when the
|
||||||
|
// caller supplies no additional parameters. URLComponents.queryItems decodes
|
||||||
|
// then re-encodes retained items, which changes values such as alpha%2Bbeta.
|
||||||
|
private static func updateQueryItems(
|
||||||
|
_ items: [URLQueryItem],
|
||||||
|
in components: inout URLComponents,
|
||||||
|
replacingExisting: Bool = false
|
||||||
|
) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
|
||||||
|
var additions = URLComponents()
|
||||||
|
additions.queryItems = items
|
||||||
|
|
||||||
|
var existing = components.percentEncodedQueryItems ?? []
|
||||||
|
if replacingExisting {
|
||||||
|
let replacementNames = items.map { $0.name }
|
||||||
|
existing.removeAll { item in
|
||||||
|
guard let name = item.name.removingPercentEncoding else { return false }
|
||||||
|
return replacementNames.contains(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
components.percentEncodedQueryItems = existing + (additions.percentEncodedQueryItems ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
private func auth(response: HTTPURLResponse) async throws {
|
private func auth(response: HTTPURLResponse) async throws {
|
||||||
// Process WWW-Authenticate header
|
// Process WWW-Authenticate header
|
||||||
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||||
@@ -420,7 +444,7 @@ class Registry {
|
|||||||
// >[...] using the service and scope values from the WWW-Authenticate header.
|
// >[...] using the service and scope values from the WWW-Authenticate header.
|
||||||
//
|
//
|
||||||
// [1]: https://docs.docker.com/registry/spec/auth/token/
|
// [1]: https://docs.docker.com/registry/spec/auth/token/
|
||||||
authenticateURL.queryItems = ["scope", "service"].compactMap { key in
|
let challengeQueryItems: [URLQueryItem] = ["scope", "service"].compactMap { key in
|
||||||
if let value = wwwAuthenticate.kvs[key] {
|
if let value = wwwAuthenticate.kvs[key] {
|
||||||
return URLQueryItem(name: key, value: value)
|
return URLQueryItem(name: key, value: value)
|
||||||
} else {
|
} else {
|
||||||
@@ -428,6 +452,8 @@ class Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Self.updateQueryItems(challengeQueryItems, in: &authenticateURL, replacingExisting: true)
|
||||||
|
|
||||||
var headers: Dictionary<String, String> = Dictionary()
|
var headers: Dictionary<String, String> = Dictionary()
|
||||||
|
|
||||||
if let (user, password) = try lookupCredentials() {
|
if let (user, password) = try lookupCredentials() {
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ class WWWAuthenticate {
|
|||||||
+ "key=value or key=\"value\"")
|
+ "key=value or key=\"value\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = String(parts[0])
|
// Directives can be surrounded by optional whitespace[1]
|
||||||
var value = String(parts[1])
|
//
|
||||||
|
// [1]: https://www.rfc-editor.org/rfc/rfc7235#section-4.1
|
||||||
|
let key = parts[0].trimmingCharacters(in: .whitespaces)
|
||||||
|
var value = parts[1].trimmingCharacters(in: .whitespaces)
|
||||||
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||||
|
|
||||||
kvs[key] = value
|
kvs[key] = value
|
||||||
|
|||||||
@@ -1,7 +1,42 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
struct Linux: Platform {
|
struct Linux: PlatformSuspendable {
|
||||||
|
|
||||||
|
var machineIdentifier: VZGenericMachineIdentifier?
|
||||||
|
|
||||||
|
init(machineIdentifier: VZGenericMachineIdentifier = VZGenericMachineIdentifier()) {
|
||||||
|
self.machineIdentifier = machineIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
guard let encodedMachineIdentifier = try container.decodeIfPresent(
|
||||||
|
String.self, forKey: .machineIdentifier
|
||||||
|
) else {
|
||||||
|
self.machineIdentifier = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = Data.init(base64Encoded: encodedMachineIdentifier) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .machineIdentifier,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize Data using the provided value")
|
||||||
|
}
|
||||||
|
guard let machineIdentifier = VZGenericMachineIdentifier.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .machineIdentifier,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize VZGenericMachineIdentifier using the provided value")
|
||||||
|
}
|
||||||
|
self.machineIdentifier = machineIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encodeIfPresent(machineIdentifier?.dataRepresentation.base64EncodedString(), forKey: .machineIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
func os() -> OS {
|
func os() -> OS {
|
||||||
.linux
|
.linux
|
||||||
}
|
}
|
||||||
@@ -16,6 +51,11 @@ struct Linux: Platform {
|
|||||||
|
|
||||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
let config = VZGenericPlatformConfiguration()
|
let config = VZGenericPlatformConfiguration()
|
||||||
|
|
||||||
|
if let machineIdentifier {
|
||||||
|
config.machineIdentifier = machineIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
if #available(macOS 15, *) {
|
if #available(macOS 15, *) {
|
||||||
config.isNestedVirtualizationEnabled = needsNestedVirtualization
|
config.isNestedVirtualizationEnabled = needsNestedVirtualization
|
||||||
}
|
}
|
||||||
@@ -47,4 +87,14 @@ struct Linux: Platform {
|
|||||||
// Linux doesn't support trackpad, so just return the regular pointing devices
|
// Linux doesn't support trackpad, so just return the regular pointing devices
|
||||||
return pointingDevices(noUSB: noUSB)
|
return pointingDevices(noUSB: noUSB)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
|
// VZUSBScreenCoordinatePointingDeviceConfiguration passes save/restore
|
||||||
|
// validation, but causes restoring a Linux VM to fail with "invalid argument".
|
||||||
|
[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
|
keyboards(noUSB: noUSB)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ extension URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
||||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
let times = [accessDate.asTimespec(), timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT))]
|
||||||
let modificationDate = attrs.contentAccessDate!
|
let ret = utimensat(AT_FDCWD, path, times, 0)
|
||||||
|
|
||||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
|
||||||
let ret = utimes(path, times)
|
|
||||||
if ret != 0 {
|
if ret != 0 {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
|
throw RuntimeError.FailedToUpdateAccessDate("utimensat(2) failed: \(details)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Date {
|
extension Date {
|
||||||
func asTimeval() -> timeval {
|
func asTimespec() -> timespec {
|
||||||
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
let seconds = floor(timeIntervalSince1970)
|
||||||
|
let nanoseconds = (timeIntervalSince1970 - seconds) * 1_000_000_000
|
||||||
|
|
||||||
|
return timespec(tv_sec: Int(seconds), tv_nsec: Int(nanoseconds))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ enum CodingKeys: String, CodingKey {
|
|||||||
// macOS-specific keys
|
// macOS-specific keys
|
||||||
case ecid
|
case ecid
|
||||||
case hardwareModel
|
case hardwareModel
|
||||||
|
|
||||||
|
// Linux-specific keys
|
||||||
|
case machineIdentifier
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMDisplayConfig: Codable, Equatable {
|
struct VMDisplayConfig: Codable, Equatable {
|
||||||
|
|||||||
@@ -230,6 +230,21 @@ struct VMDirectory: Prunable {
|
|||||||
try vmConfig.save(toURL: configURL)
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func regenerateLinuxMachineIdentifier() throws {
|
||||||
|
var vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
guard var vmLinux = vmConfig.platform as? Linux else {
|
||||||
|
throw RuntimeError.VMConfigurationError("cannot regenerate a Linux machine identifier on a non-Linux VM")
|
||||||
|
}
|
||||||
|
|
||||||
|
vmLinux.machineIdentifier = VZGenericMachineIdentifier()
|
||||||
|
vmConfig.platform = vmLinux
|
||||||
|
|
||||||
|
// cleanup state if any
|
||||||
|
try? FileManager.default.removeItem(at: stateURL)
|
||||||
|
|
||||||
|
try vmConfig.save(toURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
func resizeDisk(
|
func resizeDisk(
|
||||||
_ sizeGB: UInt16,
|
_ sizeGB: UInt16,
|
||||||
format: DiskImageFormat = .raw,
|
format: DiskImageFormat = .raw,
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class VMStorageHelper {
|
|||||||
|
|
||||||
extension NSError {
|
extension NSError {
|
||||||
func isFileNotFound() -> Bool {
|
func isFileNotFound() -> Bool {
|
||||||
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
return self.domain == NSCocoaErrorDomain &&
|
||||||
|
(self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class ARPCacheTests: XCTestCase {
|
||||||
|
private let mac = MACAddress(fromString: "00:11:22:33:44:55")!
|
||||||
|
|
||||||
|
private func cache(_ output: String, exitCode: Int = 0) throws -> ARPCache {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let command = directory.appendingPathComponent("arp-fixture")
|
||||||
|
let quoted = output.replacingOccurrences(of: "'", with: "'\"'\"'")
|
||||||
|
try "#!/bin/sh\nprintf '%s' '\(quoted)'\nexit \(exitCode)\n".write(to: command, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: command.path)
|
||||||
|
return try ARPCache(arpCommandURL: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptySuccessfulOutputHasNoAddress() throws {
|
||||||
|
XCTAssertNil(try cache("").ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWhitespaceSuccessfulOutputHasNoAddress() throws {
|
||||||
|
for output in ["\n", " \t\r\n", "\n\n"] {
|
||||||
|
XCTAssertNil(try cache(output).ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyOutputPreservesCommandFailure() throws {
|
||||||
|
XCTAssertThrowsError(try cache("", exitCode: 7)) { error in
|
||||||
|
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonemptyOutputPreservesCommandFailure() throws {
|
||||||
|
XCTAssertThrowsError(try cache("synthetic failure", exitCode: 9)) { error in
|
||||||
|
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidEntryResolvesAddress() throws {
|
||||||
|
let value = try cache("? (192.0.2.8) at 0:11:22:33:44:55 on en0 ifscope [ethernet]\n")
|
||||||
|
XCTAssertEqual(try value.ResolveMACAddress(macAddress: mac), IPv4Address("192.0.2.8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIncompleteAndOtherEntriesDoNotMatch() throws {
|
||||||
|
let value = try cache("""
|
||||||
|
? (192.0.2.9) at (incomplete) on en0 ifscope [ethernet]
|
||||||
|
? (192.0.2.10) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
|
||||||
|
""")
|
||||||
|
XCTAssertNil(try value.ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMalformedNonemptyOutputStillFails() throws {
|
||||||
|
XCTAssertThrowsError(try cache("synthetic invalid entry").ResolveMACAddress(macAddress: mac)) {
|
||||||
|
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidAddressesStillFail() throws {
|
||||||
|
for line in [
|
||||||
|
"? (not-an-ip) at 0:11:22:33:44:55 on en0 ifscope [ethernet]",
|
||||||
|
"? (192.0.2.8) at not-a-mac on en0 ifscope [ethernet]",
|
||||||
|
] {
|
||||||
|
XCTAssertThrowsError(try cache(line).ResolveMACAddress(macAddress: mac)) {
|
||||||
|
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import XCTest
|
||||||
|
import Network
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class BootptabTests: XCTestCase {
|
||||||
|
func testCommentedReservation() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
let contents = """
|
||||||
|
%
|
||||||
|
#client1 1 02:ab:00:01:02:03 192.168.64.2
|
||||||
|
"""
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let bootptab = try XCTUnwrap(Bootptab(url))
|
||||||
|
|
||||||
|
XCTAssertNil(try bootptab.ResolveMACAddress(
|
||||||
|
macAddress: MACAddress(fromString: "02:ab:00:01:02:03")!))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCommentedReservationWithActiveReservation() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
let contents = """
|
||||||
|
%
|
||||||
|
#client1 1 02:ab:00:01:02:03 192.168.64.2
|
||||||
|
client1 1 02:ab:00:01:02:03 192.168.64.3
|
||||||
|
"""
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let bootptab = try XCTUnwrap(Bootptab(url))
|
||||||
|
|
||||||
|
XCTAssertEqual(try bootptab.ResolveMACAddress(
|
||||||
|
macAddress: MACAddress(fromString: "02:ab:00:01:02:03")!), IPv4Address("192.168.64.3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResolveMACAddress() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
// A missing file produces no Bootptab
|
||||||
|
XCTAssertNil(try Bootptab(url))
|
||||||
|
|
||||||
|
// Write reservations with duplicates, conflicts, and a malformed MAC address
|
||||||
|
let contents = """
|
||||||
|
# DHCP reservations
|
||||||
|
%
|
||||||
|
client1 1 02:ab:00:01:02:03 192.168.64.2
|
||||||
|
client2 1 02:ab:00:01:02:03 192.168.64.2
|
||||||
|
client3 1 02:ab:00:01:02:04 192.168.64.3
|
||||||
|
client4 1 02:ab:00:01:02:04 192.168.65.3
|
||||||
|
malformed 1 02:gg:00:01:02:03 192.168.64.9
|
||||||
|
"""
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
// Parse the reservation file
|
||||||
|
let bootptab = try XCTUnwrap(Bootptab(url))
|
||||||
|
|
||||||
|
// Identical reservations resolve to one address
|
||||||
|
XCTAssertEqual(try bootptab.ResolveMACAddress(
|
||||||
|
macAddress: MACAddress(fromString: "02:ab:00:01:02:03")!), IPv4Address("192.168.64.2"))
|
||||||
|
|
||||||
|
// An unknown MAC address has no reservation
|
||||||
|
XCTAssertNil(try bootptab.ResolveMACAddress(
|
||||||
|
macAddress: MACAddress(fromString: "02:ab:00:01:02:05")!))
|
||||||
|
|
||||||
|
// Conflicting reservations produce an error
|
||||||
|
XCTAssertThrowsError(try bootptab.ResolveMACAddress(
|
||||||
|
macAddress: MACAddress(fromString: "02:ab:00:01:02:04")!))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,45 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListSkipsLocalVMsDeletedDuringMetadataLookup() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: ["--source", "local"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSkipsOCIVMsDeletedDuringMetadataLookup() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: ["--source", "oci"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSkipsVMsDeletedDuringMetadataLookupAcrossSources() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListPreservesMetadataPermissionErrors() async throws {
|
||||||
|
for source in ["local", "oci"] {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let vmDir = try source == "local" ? VMStorageLocal().create("inaccessible") :
|
||||||
|
VMStorageOCI().create(RemoteName("example.com/org/inaccessible:latest"))
|
||||||
|
var vmConfig = config()
|
||||||
|
vmConfig.diskFormat = .asif
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: vmDir.baseURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/chmod 000 \"${4%/*}\"")
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
do {
|
||||||
|
_ = try await commandOutput(List.self, ["--source", source, "--format", "json"])
|
||||||
|
XCTFail("list should report metadata permission errors")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual((error as NSError).domain, NSCocoaErrorDomain)
|
||||||
|
XCTAssertEqual((error as NSError).code, NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testGetSurvivesUnavailableDiskCapacity() async throws {
|
func testGetSurvivesUnavailableDiskCapacity() async throws {
|
||||||
try await withTemporaryTartHome {
|
try await withTemporaryTartHome {
|
||||||
let previousPath = try installUnavailableDiskutil()
|
let previousPath = try installUnavailableDiskutil()
|
||||||
@@ -116,6 +155,66 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFileNotFoundRequiresCocoaErrorDomain() {
|
||||||
|
XCTAssertTrue(NSError(domain: NSCocoaErrorDomain, code: NSFileNoSuchFileError).isFileNotFound())
|
||||||
|
XCTAssertTrue(NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoSuchFileError).isFileNotFound())
|
||||||
|
XCTAssertFalse(RuntimeError.VMIsRunning("running").isFileNotFound())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloneRejectsExistingDestinationUnlessOverwriteIsRequested() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let source = try makeStandaloneVM(named: "source", diskContents: "source")
|
||||||
|
let destination = try makeStandaloneVM(named: "destination", diskContents: "existing")
|
||||||
|
|
||||||
|
let command = try Clone.parseAsRoot(["source", "destination"]) as! Clone
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await command.run()
|
||||||
|
XCTFail("expected cloning over an existing VM to be rejected")
|
||||||
|
} catch let error as ValidationError {
|
||||||
|
XCTAssertEqual(error.message, "VM \"destination\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: destination.diskURL), Data("existing".utf8))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: source.diskURL.path))
|
||||||
|
|
||||||
|
let overwriteCommand = try Clone.parseAsRoot(["--overwrite", "source", "destination"]) as! Clone
|
||||||
|
try await overwriteCommand.run()
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: destination.diskURL), Data("source".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClonePreservesIncompleteDestination() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
_ = try makeStandaloneVM(named: "source", diskContents: "source")
|
||||||
|
let storage = try VMStorageLocal()
|
||||||
|
let destination = storage.baseURL.appendingPathComponent("destination", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||||
|
let diskURL = destination.appendingPathComponent("disk.img")
|
||||||
|
try Data("existing".utf8).write(to: diskURL)
|
||||||
|
XCTAssertFalse(storage.exists("destination"))
|
||||||
|
|
||||||
|
// Check both a local source and rejection before any remote registry access.
|
||||||
|
for source in ["source", "invalid.invalid/image:latest"] {
|
||||||
|
let command = try Clone.parseAsRoot([source, "destination"]) as! Clone
|
||||||
|
do {
|
||||||
|
try await command.run()
|
||||||
|
XCTFail("expected an incomplete destination to be preserved")
|
||||||
|
} catch let error as ValidationError {
|
||||||
|
XCTAssertEqual(error.message, "VM \"destination\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(try Data(contentsOf: diskURL), Data("existing".utf8))
|
||||||
|
XCTAssertFalse(storage.exists("destination"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = try Clone.parseAsRoot(["--overwrite", "source", "destination"]) as! Clone
|
||||||
|
try await command.run()
|
||||||
|
XCTAssertEqual(try Data(contentsOf: diskURL), Data("source".utf8))
|
||||||
|
XCTAssertTrue(storage.exists("destination"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
||||||
try await withTemporaryTartHome {
|
try await withTemporaryTartHome {
|
||||||
let vmDir = try VMStorageLocal().create("stacked")
|
let vmDir = try VMStorageLocal().create("stacked")
|
||||||
@@ -146,6 +245,38 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSetDiskReplacesDiskWhenTartHomeContainsSpaces() async throws {
|
||||||
|
let home = try temporaryDirectory().appendingPathComponent("Tart Home", isDirectory: true)
|
||||||
|
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||||
|
setenv("TART_HOME", home.path, 1)
|
||||||
|
defer { restoreEnvironment("TART_HOME", to: previousHome) }
|
||||||
|
|
||||||
|
let vmDir = try makeStandaloneVM(named: "spaces", diskContents: "disk")
|
||||||
|
let replacementURL = try temporaryDirectory().appendingPathComponent("replacement.img")
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: replacementURL.path, contents: Data("replacement".utf8)))
|
||||||
|
|
||||||
|
let command = try Set.parseAsRoot(["spaces", "--disk", replacementURL.path]) as! Set
|
||||||
|
try await command.run()
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: vmDir.diskURL), Data("replacement".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSetPreservesDisplayRefitWhenChangingOtherOptions() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let vmDir = try makeStandaloneVM(named: "display-refit", diskContents: "disk")
|
||||||
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
vmConfig.displayRefit = false
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
let command = try Set.parseAsRoot(["display-refit", "--cpu", "4"]) as! Set
|
||||||
|
try await command.run()
|
||||||
|
|
||||||
|
let updatedConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
XCTAssertEqual(updatedConfig.displayRefit, false)
|
||||||
|
XCTAssertEqual(updatedConfig.cpuCount, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
||||||
try withTemporaryTartHome {
|
try withTemporaryTartHome {
|
||||||
let storage = try VMStorageOCI()
|
let storage = try VMStorageOCI()
|
||||||
@@ -193,6 +324,46 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func assertListSurvivesDeletion(sourceArguments: [String]) async throws {
|
||||||
|
let digest = "sha256:" + String(repeating: "a", count: 64)
|
||||||
|
for outputArguments in [["--format", "json"], [], ["--quiet"]] {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
// diskutil runs after enumeration, making the deletion race deterministic.
|
||||||
|
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/rm -rf \"${4%/*}\"")
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
|
||||||
|
let local = try VMStorageLocal()
|
||||||
|
let oci = try VMStorageOCI()
|
||||||
|
for (name, diskFormat) in [("disappearing", DiskImageFormat.asif), ("healthy", .raw)] {
|
||||||
|
let remoteName = try RemoteName("example.com/org/\(name)@\(digest)")
|
||||||
|
for vmDir in [try local.create(name), try oci.create(remoteName)] {
|
||||||
|
var vmConfig = config()
|
||||||
|
vmConfig.diskFormat = diskFormat
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arguments = sourceArguments + outputArguments
|
||||||
|
let output = try await commandOutput(List.self, arguments)
|
||||||
|
let expectedNames = sourceArguments.isEmpty ? ["healthy", "example.com/org/healthy@\(digest)"] :
|
||||||
|
[sourceArguments.last == "local" ? "healthy" : "example.com/org/healthy@\(digest)"]
|
||||||
|
if outputArguments == ["--format", "json"] {
|
||||||
|
let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]])
|
||||||
|
XCTAssertEqual(rows.compactMap { $0["Name"] as? String }, expectedNames)
|
||||||
|
} else if outputArguments == ["--quiet"] {
|
||||||
|
XCTAssertEqual(output.split(separator: "\n").map(String.init), expectedNames)
|
||||||
|
} else {
|
||||||
|
for name in expectedNames {
|
||||||
|
XCTAssertTrue(output.contains(name))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(output.contains("disappearing"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func config() -> VMConfig {
|
private func config() -> VMConfig {
|
||||||
VMConfig(
|
VMConfig(
|
||||||
platform: Linux(),
|
platform: Linux(),
|
||||||
@@ -202,6 +373,14 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeStandaloneVM(named name: String, diskContents: String) throws -> VMDirectory {
|
||||||
|
let vmDir = try VMStorageLocal().create(name)
|
||||||
|
try config().save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data(diskContents.utf8)))
|
||||||
|
return vmDir
|
||||||
|
}
|
||||||
|
|
||||||
private func temporaryEntries() throws -> [URL] {
|
private func temporaryEntries() throws -> [URL] {
|
||||||
try FileManager.default.contentsOfDirectory(
|
try FileManager.default.contentsOfDirectory(
|
||||||
at: Config().tartTmpDir,
|
at: Config().tartTmpDir,
|
||||||
@@ -209,11 +388,12 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installUnavailableDiskutil() throws -> String? {
|
private func installUnavailableDiskutil(beforeFailure: String = "") throws -> String? {
|
||||||
let binDirectory = try temporaryDirectory()
|
let binDirectory = try temporaryDirectory()
|
||||||
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
||||||
let script = """
|
let script = """
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
\(beforeFailure)
|
||||||
echo 'Resource temporarily unavailable' >&2
|
echo 'Resource temporarily unavailable' >&2
|
||||||
exit 1
|
exit 1
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import NIO
|
import NIO
|
||||||
import XCTest
|
import XCTest
|
||||||
|
@testable import NIOPosix
|
||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
||||||
@@ -30,6 +31,88 @@ final class ControlSocketTests: XCTestCase {
|
|||||||
try await eventLoopGroup.shutdownGracefully()
|
try await eventLoopGroup.shutdownGracefully()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAcceptErrorsRetryReadingAndForwardOtherErrors() async throws {
|
||||||
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
|
defer {
|
||||||
|
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||||
|
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
|
||||||
|
let controlSocket = try await ControlSocket(socketURL)
|
||||||
|
let channel = controlSocket.serverChannel.channel
|
||||||
|
let observations = try await channel.eventLoop.submit {
|
||||||
|
let observer = AcceptErrorObserver()
|
||||||
|
// Placing this after the recovery handler also detects context.read(), which
|
||||||
|
// bypasses downstream backpressure handlers instead of starting at the tail.
|
||||||
|
try channel.pipeline.syncOperations.addHandler(observer)
|
||||||
|
for _ in 0..<3 {
|
||||||
|
channel.pipeline.fireErrorCaught(NIOFcntlFailedError())
|
||||||
|
}
|
||||||
|
let retries = observer.readCount
|
||||||
|
let transientErrors = observer.errors.count
|
||||||
|
channel.pipeline.fireErrorCaught(ChannelError.inputClosed)
|
||||||
|
return (retries, transientErrors, observer.readCount,
|
||||||
|
observer.errors.count, observer.errors.first as? ChannelError)
|
||||||
|
}.get()
|
||||||
|
|
||||||
|
try await controlSocket.serverChannel.executeThenClose { _ in }
|
||||||
|
try await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
|
||||||
|
XCTAssertEqual(observations.0, 3)
|
||||||
|
XCTAssertEqual(observations.1, 0)
|
||||||
|
XCTAssertEqual(observations.2, 3)
|
||||||
|
XCTAssertEqual(observations.3, 1)
|
||||||
|
XCTAssertEqual(observations.4, .inputClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptErrorsDoNotEndInboundConnections() async throws {
|
||||||
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
|
defer {
|
||||||
|
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||||
|
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
|
||||||
|
let controlSocket = try await ControlSocket(socketURL)
|
||||||
|
let serverChannel = controlSocket.serverChannel
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await serverChannel.executeThenClose { inbound in
|
||||||
|
// Bound the wait if the listener stays open but stops accepting connections.
|
||||||
|
let timeout = serverChannel.channel.eventLoop.scheduleTask(in: .seconds(10)) {
|
||||||
|
serverChannel.channel.close(promise: nil)
|
||||||
|
}
|
||||||
|
defer { timeout.cancel() }
|
||||||
|
|
||||||
|
var iterator = inbound.makeAsyncIterator()
|
||||||
|
for _ in 0..<3 {
|
||||||
|
try await serverChannel.channel.eventLoop.submit {
|
||||||
|
serverChannel.channel.pipeline.fireErrorCaught(NIOFcntlFailedError())
|
||||||
|
}.get()
|
||||||
|
|
||||||
|
let clientChannel = try await ClientBootstrap(group: controlSocket.eventLoopGroup)
|
||||||
|
.connectTimeout(.seconds(5))
|
||||||
|
.connect(unixDomainSocketPath: socketURL.path)
|
||||||
|
.get()
|
||||||
|
defer { clientChannel.close(promise: nil) }
|
||||||
|
|
||||||
|
// ControlSocket.run() consumes this stream. A recoverable accept error
|
||||||
|
// must not prevent it from receiving the next connection.
|
||||||
|
let nextChannel = try await iterator.next()
|
||||||
|
let acceptedChannel = try XCTUnwrap(nextChannel, "The listener stopped delivering connections after an accept error")
|
||||||
|
try await acceptedChannel.executeThenClose { _, _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
}
|
||||||
|
|
||||||
func testInitializerPropagatesControlSocketCreationFailure() async throws {
|
func testInitializerPropagatesControlSocketCreationFailure() async throws {
|
||||||
let temporaryDirectory = try makeTemporaryDirectory()
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
let originalDirectory = FileManager.default.currentDirectoryPath
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
@@ -94,3 +177,20 @@ final class ControlSocketTests: XCTestCase {
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class AcceptErrorObserver: ChannelDuplexHandler {
|
||||||
|
typealias InboundIn = Channel
|
||||||
|
typealias OutboundIn = ByteBuffer
|
||||||
|
|
||||||
|
var readCount = 0
|
||||||
|
var errors: [Error] = []
|
||||||
|
|
||||||
|
func read(context: ChannelHandlerContext) {
|
||||||
|
readCount += 1
|
||||||
|
context.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||||
|
errors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,4 +13,12 @@ final class DockerConfigTests: XCTestCase {
|
|||||||
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAuthPasswordWithColons() throws {
|
||||||
|
let auth = Data("user:token:with:colons".utf8).base64EncodedString()
|
||||||
|
let credentials = try XCTUnwrap(DockerAuthConfig(auth: auth).decodeCredentials())
|
||||||
|
|
||||||
|
XCTAssertEqual(credentials.0, "user")
|
||||||
|
XCTAssertEqual(credentials.1, "token:with:colons")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,38 @@ final class OCIManifestTests: XCTestCase {
|
|||||||
XCTAssertNil(manifest.diskBlockCount())
|
XCTAssertNil(manifest.diskBlockCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOCIConfigIncludesRootFS() throws {
|
||||||
|
let config = OCIConfig(architecture: .arm64, os: .darwin, config: .init(Labels: [diskFormatLabel: "raw"]))
|
||||||
|
let json = try JSONSerialization.jsonObject(with: config.toJSON()) as! [String: Any]
|
||||||
|
|
||||||
|
let rootfs = try XCTUnwrap(json["rootfs"] as? [String: Any])
|
||||||
|
XCTAssertEqual(rootfs["type"] as? String, "layers")
|
||||||
|
XCTAssertEqual(rootfs["diff_ids"] as? [String], [])
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
String(decoding: try config.toJSON(), as: UTF8.self),
|
||||||
|
#"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"darwin","rootfs":{"diff_ids":[],"type":"layers"}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOCIConfigWithoutRootFSDecodes() throws {
|
||||||
|
// OCI config as pushed by Tart 2.40.1 and earlier
|
||||||
|
let json = #"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"linux"}"#
|
||||||
|
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: Data(json.utf8))
|
||||||
|
|
||||||
|
XCTAssertEqual(config.architecture, .arm64)
|
||||||
|
XCTAssertEqual(config.os, .linux)
|
||||||
|
XCTAssertEqual(config.config?.Labels, [diskFormatLabel: "raw"])
|
||||||
|
XCTAssertNil(config.rootfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOCIConfigRootFSRoundTrips() throws {
|
||||||
|
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: OCIConfig().toJSON())
|
||||||
|
|
||||||
|
XCTAssertEqual(config.rootfs?.type, "layers")
|
||||||
|
XCTAssertEqual(config.rootfs?.diffIDs, [])
|
||||||
|
}
|
||||||
|
|
||||||
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
|
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
|
||||||
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
|
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
|
||||||
XCTAssertEqual(error as? OCIManifestValidationError, expected)
|
XCTAssertEqual(error as? OCIManifestValidationError, expected)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class RegistryRealmTests: XCTestCase {
|
||||||
|
func testPreservesRealmQueryParameters() async throws {
|
||||||
|
try await checkAuthentication(mode: "preserve")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChallengeReplacesOnlyItsOwnQueryParameters() async throws {
|
||||||
|
try await checkAuthentication(mode: "replace")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRealmWithoutQueryParameters() async throws {
|
||||||
|
try await checkAuthentication(mode: "plain")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPreservesOriginalPercentEncodingAndReplacesEncodedChallengeKeys() async throws {
|
||||||
|
try await checkAuthentication(mode: "encoded")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testChallengeWithSpacesAfterCommas() async throws {
|
||||||
|
try await checkAuthentication(mode: "spaced")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkAuthentication(mode: String) async throws {
|
||||||
|
let server = try RealmTokenServer(mode: mode)
|
||||||
|
defer { server.stop() }
|
||||||
|
let port = try await server.start()
|
||||||
|
let registry = try Registry(host: "127.0.0.1:\(port)", namespace: "org/repo", insecure: true, credentialsProviders: [])
|
||||||
|
try await registry.ping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RealmTokenServer {
|
||||||
|
private let listener: NWListener
|
||||||
|
private let queue = DispatchQueue(label: "registry-realm-test")
|
||||||
|
private let mode: String
|
||||||
|
|
||||||
|
init(mode: String) throws {
|
||||||
|
self.mode = mode
|
||||||
|
let parameters = NWParameters.tcp
|
||||||
|
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
|
||||||
|
listener = try NWListener(using: parameters)
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() async throws -> UInt16 {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
listener.stateUpdateHandler = { state in
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
self.listener.stateUpdateHandler = nil
|
||||||
|
continuation.resume(returning: self.listener.port!.rawValue)
|
||||||
|
case .failed(let error):
|
||||||
|
self.listener.stateUpdateHandler = nil
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener.newConnectionHandler = { connection in
|
||||||
|
connection.start(queue: self.queue)
|
||||||
|
self.receive(connection, buffered: Data())
|
||||||
|
}
|
||||||
|
listener.start(queue: queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() { listener.cancel() }
|
||||||
|
|
||||||
|
private func receive(_ connection: NWConnection, buffered: Data) {
|
||||||
|
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, complete, error in
|
||||||
|
let received = buffered + (data ?? Data())
|
||||||
|
guard received.range(of: Data("\r\n\r\n".utf8)) != nil else {
|
||||||
|
if error != nil || complete { connection.cancel() }
|
||||||
|
else { self.receive(connection, buffered: received) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let request = String(decoding: received, as: UTF8.self)
|
||||||
|
let target = request.split(separator: " ")[1]
|
||||||
|
let url = URL(string: "http://127.0.0.1:\(self.listener.port!.rawValue)\(target)")!
|
||||||
|
var status = "200 OK"
|
||||||
|
var extraHeaders = ""
|
||||||
|
var body = "{}"
|
||||||
|
if url.path == "/v2" || url.path == "/v2/" {
|
||||||
|
if !request.lowercased().contains("authorization: bearer test-token\r\n") {
|
||||||
|
let query: String
|
||||||
|
switch self.mode {
|
||||||
|
case "plain", "spaced": query = ""
|
||||||
|
case "encoded": query = "?tenant=alpha%2Bbeta&escape=%7e%2f&mode=one&mode=two&empty="
|
||||||
|
default: query = "?tenant=alpha%26beta&mode=one&mode=two&empty="
|
||||||
|
}
|
||||||
|
let stale = self.mode == "replace" ? "&scope=old&service=old" :
|
||||||
|
(self.mode == "encoded" ? "&sc%6Fpe=old&service=old" : "")
|
||||||
|
let realm = "http://127.0.0.1:\(self.listener.port!.rawValue)/token\(query)\(stale)"
|
||||||
|
status = "401 Unauthorized"
|
||||||
|
let separator = self.mode == "spaced" ? ", " : ","
|
||||||
|
extraHeaders = "WWW-Authenticate: Bearer realm=\"\(realm)\"\(separator)service=\"registry\"\(separator)scope=\"repository:org/repo:pull\"\r\n"
|
||||||
|
}
|
||||||
|
} else if url.path == "/token" {
|
||||||
|
let receivedURL = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
||||||
|
let items = receivedURL.queryItems ?? []
|
||||||
|
let values = { (name: String) in items.filter { $0.name == name }.map { $0.value ?? "" } }
|
||||||
|
let expectedRaw = "tenant=alpha%2Bbeta&escape=%7e%2f&mode=one&mode=two&empty=&"
|
||||||
|
let encodedValid = receivedURL.percentEncodedQuery?.hasPrefix(expectedRaw) == true &&
|
||||||
|
values("tenant") == ["alpha+beta"] && values("escape") == ["~/"]
|
||||||
|
let normalValid = values("tenant") == ["alpha&beta"]
|
||||||
|
let valid = values("service") == ["registry"] && values("scope") == ["repository:org/repo:pull"] &&
|
||||||
|
(self.mode == "plain" || self.mode == "spaced" || ((self.mode == "encoded" ? encodedValid : normalValid) &&
|
||||||
|
values("mode") == ["one", "two"] && values("empty") == [""]))
|
||||||
|
status = valid ? "200 OK" : "400 Bad Request"
|
||||||
|
body = valid ? "{\"token\":\"test-token\"}" : "{\"error\":\"missing or changed realm parameters\"}"
|
||||||
|
} else {
|
||||||
|
status = "404 Not Found"
|
||||||
|
}
|
||||||
|
let response = "HTTP/1.1 \(status)\r\n\(extraHeaders)Content-Type: application/json\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
|
||||||
|
connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in connection.cancel() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,17 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
|
func testFractionalIssuedAt() throws {
|
||||||
|
for timestamp in ["1970-01-01T00:00:00.125Z", "1970-01-01T01:30:00.125+01:30", "1970-01-01T00:00:00.125000000Z"] {
|
||||||
|
let data = Data("{\"token\":\"test-token\",\"expires_in\":3600,\"issued_at\":\"\(timestamp)\"}".utf8)
|
||||||
|
let response = try TokenResponse.parse(fromData: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.issuedAt!.timeIntervalSince1970, 0.125, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(response.tokenExpiresAt.timeIntervalSince1970, 3600.125, accuracy: 0.0001)
|
||||||
|
XCTAssertFalse(response.isValid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
var expectedTokenExpiresAtRange = DateInterval()
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
|
|||||||
@@ -2,21 +2,29 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class URLAccessDateTests: XCTestCase {
|
final class URLAccessDateTests: XCTestCase {
|
||||||
func testGetAndSetAccessTime() throws {
|
func testUpdateAccessDatePreservesModificationDate() throws {
|
||||||
// Create a temporary file
|
// Create a temporary file
|
||||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
||||||
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
|
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tmpFile) }
|
||||||
|
|
||||||
// Ensure it's access date is different than our desired access date
|
// Ensure its access date is different from our desired access date
|
||||||
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
let accessDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||||
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
|
let modificationDate = Date(timeIntervalSince1970: 1_577_836_800.125)
|
||||||
|
try FileManager.default.setAttributes([.modificationDate: modificationDate], ofItemAtPath: tmpFile.path)
|
||||||
|
XCTAssertNotEqual(accessDate, try tmpFile.accessDate())
|
||||||
|
|
||||||
// Set our desired access date for a file
|
// Set our desired access date for a file
|
||||||
try tmpFile.updateAccessDate(arbitraryDate)
|
try tmpFile.updateAccessDate(accessDate)
|
||||||
|
|
||||||
// Ensure the access date has changed to our value
|
// Ensure the access date has changed to our value
|
||||||
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
|
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
|
||||||
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
|
XCTAssertEqual(accessDate, try tmpFile.accessDate())
|
||||||
|
|
||||||
|
// Ensure the modification date has not changed
|
||||||
|
tmpFile.removeCachedResourceValue(forKey: .contentModificationDateKey)
|
||||||
|
let attrs = try tmpFile.resourceValues(forKeys: [.contentModificationDateKey])
|
||||||
|
XCTAssertEqual(modificationDate, try XCTUnwrap(attrs.contentModificationDate))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
final class VMConfigTests: XCTestCase {
|
final class VMConfigTests: XCTestCase {
|
||||||
func testVMDisplayConfig() throws {
|
func testVMDisplayConfig() throws {
|
||||||
// Defaults units (points)
|
// Defaults units (points)
|
||||||
@@ -15,4 +18,53 @@ final class VMConfigTests: XCTestCase {
|
|||||||
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px")
|
vmDisplayConfig = VMDisplayConfig.init(argument: "1234x5678px")
|
||||||
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig)
|
XCTAssertEqual(VMDisplayConfig(width: 1234, height: 5678, unit: .pixel), vmDisplayConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testLinuxMachineIdentifierSerialization() throws {
|
||||||
|
let originalIdentifier = VZGenericMachineIdentifier()
|
||||||
|
let originalConfig = VMConfig(
|
||||||
|
platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
let encodedConfigData = try originalConfig.toJSON()
|
||||||
|
|
||||||
|
let decodedConfig = try VMConfig(fromJSON: encodedConfigData)
|
||||||
|
let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux)
|
||||||
|
let decodedMachineIdentifier = try XCTUnwrap(decodedLinux.machineIdentifier)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
decodedMachineIdentifier.dataRepresentation,
|
||||||
|
originalIdentifier.dataRepresentation,
|
||||||
|
"decoded machine identifier should match original identifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
let platformConfiguration = try decodedLinux.platform(
|
||||||
|
nvramURL: URL(fileURLWithPath: "/dev/null"),
|
||||||
|
needsNestedVirtualization: false
|
||||||
|
)
|
||||||
|
let decodedPlatformConfiguration = try XCTUnwrap(
|
||||||
|
platformConfiguration as? VZGenericPlatformConfiguration
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
decodedPlatformConfiguration.machineIdentifier.dataRepresentation,
|
||||||
|
originalIdentifier.dataRepresentation,
|
||||||
|
"platform configuration should reuse decoded machine identifier"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyLinuxConfigWithoutMachineIdentifier() throws {
|
||||||
|
let originalIdentifier = VZGenericMachineIdentifier()
|
||||||
|
let originalConfig = VMConfig(
|
||||||
|
platform: Linux(machineIdentifier: originalIdentifier), cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
let encodedConfigData = try originalConfig.toJSON()
|
||||||
|
|
||||||
|
var configJSONObject = try XCTUnwrap(JSONSerialization.jsonObject(with: encodedConfigData) as? [String: Any])
|
||||||
|
configJSONObject.removeValue(forKey: "machineIdentifier")
|
||||||
|
let legacyConfigData = try JSONSerialization.data(withJSONObject: configJSONObject)
|
||||||
|
|
||||||
|
let decodedConfig = try VMConfig(fromJSON: legacyConfigData)
|
||||||
|
let decodedLinux = try XCTUnwrap(decodedConfig.platform as? Linux)
|
||||||
|
|
||||||
|
XCTAssertNil(decodedLinux.machineIdentifier, "missing machineIdentifier should be decoded as nil")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class VMDirectoryTests: XCTestCase {
|
||||||
|
|
||||||
|
func testRegenerateLinuxMachineIdentifierReplacesIdentifierAndDeletesState() throws {
|
||||||
|
let vmDir = try temporaryVMDirectory()
|
||||||
|
let originalIdentifier = VZGenericMachineIdentifier()
|
||||||
|
let originalConfig = try writeLinuxConfig(machineIdentifier: originalIdentifier, to: vmDir)
|
||||||
|
try touch(vmDir.stateURL)
|
||||||
|
|
||||||
|
try vmDir.regenerateLinuxMachineIdentifier()
|
||||||
|
|
||||||
|
let config = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
let linux = try XCTUnwrap(config.platform as? Linux)
|
||||||
|
let newIdentifier = try XCTUnwrap(linux.machineIdentifier)
|
||||||
|
XCTAssertNotEqual(newIdentifier.dataRepresentation, originalIdentifier.dataRepresentation)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: vmDir.stateURL.path))
|
||||||
|
XCTAssertEqual(config.macAddress.string, originalConfig.macAddress.string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRegenerateLinuxMachineIdentifierAssignsMissingIdentifier() throws {
|
||||||
|
let vmDir = try temporaryVMDirectory()
|
||||||
|
try writeLinuxConfig(machineIdentifier: nil, to: vmDir)
|
||||||
|
|
||||||
|
try vmDir.regenerateLinuxMachineIdentifier()
|
||||||
|
|
||||||
|
let linux = try XCTUnwrap(VMConfig(fromURL: vmDir.configURL).platform as? Linux)
|
||||||
|
XCTAssertNotNil(linux.machineIdentifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func writeLinuxConfig(machineIdentifier: VZGenericMachineIdentifier?, to vmDir: VMDirectory) throws -> VMConfig {
|
||||||
|
var linux = Linux()
|
||||||
|
linux.machineIdentifier = machineIdentifier
|
||||||
|
let config = VMConfig(platform: linux, cpuCountMin: 2, memorySizeMin: 1024 * 1024 * 1024)
|
||||||
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
private func temporaryVMDirectory() throws -> VMDirectory {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||||
|
addTeardownBlock {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return VMDirectory(baseURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touch(_ url: URL) throws {
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: Data()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,20 @@ final class WWWAuthenticateTests: XCTestCase {
|
|||||||
XCTAssertEqual(["a": "b", "c": "d"], wwwAuthenticate.kvs)
|
XCTAssertEqual(["a": "b", "c": "d"], wwwAuthenticate.kvs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testWhitespaceAroundDirectives() throws {
|
||||||
|
// RFC 7235 allows optional whitespace around the commas separating directives[1]
|
||||||
|
//
|
||||||
|
// [1]: https://www.rfc-editor.org/rfc/rfc7235#section-4.1
|
||||||
|
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: "Bearer realm=\"https://auth.example.com/token\", service=\"registry.example.com\" , scope=\"repository:org/repo:pull,push\"")
|
||||||
|
|
||||||
|
XCTAssertEqual("Bearer", wwwAuthenticate.scheme)
|
||||||
|
XCTAssertEqual([
|
||||||
|
"realm": "https://auth.example.com/token",
|
||||||
|
"service": "registry.example.com",
|
||||||
|
"scope": "repository:org/repo:pull,push",
|
||||||
|
], wwwAuthenticate.kvs)
|
||||||
|
}
|
||||||
|
|
||||||
func testIncompleteHeader() throws {
|
func testIncompleteHeader() throws {
|
||||||
XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Whatever")) {
|
XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Whatever")) {
|
||||||
XCTAssertTrue($0 is RegistryError)
|
XCTAssertTrue($0 is RegistryError)
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ And no worries, this file will be re-created on the next `tart run`.
|
|||||||
|
|
||||||
Due to the limitations of the macOS built-in DHCP server, `tart ip` is unable to correctly report the IP addresses for VMs using DHCP client identifiers that are not based on VMs link-layer addresses (MAC addresses).
|
Due to the limitations of the macOS built-in DHCP server, `tart ip` is unable to correctly report the IP addresses for VMs using DHCP client identifiers that are not based on VMs link-layer addresses (MAC addresses).
|
||||||
|
|
||||||
By default, when [no `--resolver=arp` is specified](#resolving-the-vms-ip-when-using-bridged-networking), `tart ip` reads the `/var/db/dhcpd_leases` file and tries to find the freshest entry that matches the VM's MAC address (based on the `hw_address` field).
|
By default, when [no `--resolver=arp` is specified](#resolving-the-vms-ip-when-using-bridged-networking), `tart ip` first checks the `/etc/bootptab` file for a matching DHCP reservation. Otherwise, it reads the `/var/db/dhcpd_leases` file and tries to find the freshest entry that matches the VM's MAC address (based on the `hw_address` field).
|
||||||
|
|
||||||
However, things starts to break when the VM uses a [DUID-EN](https://metebalci.com/blog/a-note-on-dhcpv6-duid-and-prefix-delegation#duid-types) identifier, for example. One of the notorious examples of this being Ubuntu, using this type of identifier by default on latest versions. This results in the `/var/db/dhcpd_leases` entry for Ubuntu appearing as follows:
|
However, things starts to break when the VM uses a [DUID-EN](https://metebalci.com/blog/a-note-on-dhcpv6-duid-and-prefix-delegation#duid-types) identifier, for example. One of the notorious examples of this being Ubuntu, using this type of identifier by default on latest versions. This results in the `/var/db/dhcpd_leases` entry for Ubuntu appearing as follows:
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for range 2 {
|
for range 2 {
|
||||||
_, stderr, err := tart.Tart(t, "clone", "source", "destination")
|
_, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination")
|
||||||
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
|
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
|
||||||
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
|
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
|||||||
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
|
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, stderr, err := tart.Tart(t, "clone", "source", "destination"); err != nil {
|
if _, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination"); err != nil {
|
||||||
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
|
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
|
||||||
}
|
}
|
||||||
currentInfo, err := os.Stat(config)
|
currentInfo, err := os.Stat(config)
|
||||||
|
|||||||
Reference in New Issue
Block a user