mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-19 15:53:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
543453404c | ||
|
|
bb5cb01d31 | ||
|
|
6c31a73166 | ||
|
|
e2042dedcd | ||
|
|
12501370cc | ||
|
|
7d39f4cf68 | ||
|
|
8364f4ccac | ||
|
|
ef925e433d | ||
|
|
37af26a810 | ||
|
|
05edeac562 | ||
|
|
917c0b51ed | ||
|
|
e3b4f71f77 | ||
|
|
e92c3b900f | ||
|
|
5c1c6bd315 | ||
|
|
27d3e2c5da | ||
|
|
a80ec74a42 | ||
|
|
8ac52501c3 | ||
|
|
bb4acb2468 | ||
|
|
65aea029ab | ||
|
|
4e58a2a0b9 | ||
|
|
f8ce0f9acb | ||
|
|
6fff37f0e0 | ||
|
|
89017ff0b3 | ||
|
|
acaf3ca7ef |
@@ -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,6 +103,8 @@ 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 generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||||
&& sourceState != .Suspended
|
&& sourceState != .Suspended
|
||||||
@@ -151,4 +158,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct Export: AsyncParsableCommand {
|
|||||||
func userWantsOverwrite(_ filename: String) -> Bool {
|
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||||
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
|
||||||
|
|
||||||
let answer = readLine()!
|
let answer = readLine()
|
||||||
|
|
||||||
return answer == "yes"
|
return answer == "yes"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -31,12 +32,14 @@ struct Get: AsyncParsableCommand {
|
|||||||
OS: vmConfig.os,
|
OS: vmConfig.os,
|
||||||
CPU: vmConfig.cpuCount,
|
CPU: vmConfig.cpuCount,
|
||||||
Memory: memorySizeInMb,
|
Memory: memorySizeInMb,
|
||||||
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||||
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
DiskFormat: vmConfig.diskFormat.rawValue,
|
DiskFormat: vmConfig.diskFormat.rawValue,
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
|
||||||
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,30 +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",
|
||||||
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Name: name,
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Running: vmDir.running(),
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
State: vmDir.state().rawValue
|
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||||
)
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ struct Run: AsyncParsableCommand {
|
|||||||
@Flag(help: "Disable audio pass-through to host.")
|
@Flag(help: "Disable audio pass-through to host.")
|
||||||
var noAudio: Bool = false
|
var noAudio: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "Disable USB accessories.")
|
||||||
|
var noUSBAccessories: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Disable clipboard sharing between host and guest.",
|
"Disable clipboard sharing between host and guest.",
|
||||||
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
discussion: "Clipboard sharing requires spice-vdagent package on Linux and https://github.com/cirruslabs/tart-guest-agent on macOS."))
|
||||||
@@ -184,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).
|
||||||
@@ -329,9 +343,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 {
|
||||||
@@ -425,7 +440,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
try vmDir.regenerateMACAddress()
|
try vmDir.regenerateMACAddress()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
if netSoftnet && isInteractiveSession() {
|
||||||
try Softnet.configureSUIDBitIfNeeded()
|
try Softnet.configureSUIDBitIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +485,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
nested: nested,
|
nested: nested,
|
||||||
audio: !noAudio,
|
audio: !noAudio,
|
||||||
clipboard: !noClipboard,
|
clipboard: !noClipboard,
|
||||||
|
noUSBAccessories: noUSBAccessories,
|
||||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||||
noTrackpad: noTrackpad,
|
noTrackpad: noTrackpad,
|
||||||
@@ -699,9 +715,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 {
|
||||||
@@ -719,6 +737,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()
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Darwin
|
||||||
|
import System
|
||||||
|
import Virtualization
|
||||||
import Network
|
import Network
|
||||||
import os.log
|
import os.log
|
||||||
import NIO
|
import NIO
|
||||||
@@ -34,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>(
|
||||||
@@ -72,7 +81,13 @@ class ControlSocket {
|
|||||||
|
|
||||||
self.logger.info("running control socket proxy")
|
self.logger.info("running control socket proxy")
|
||||||
|
|
||||||
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
|
// Duplicate the connection's file descriptor
|
||||||
|
//
|
||||||
|
// This way VZVirtioSocketConnection and NIO won't race to close the same descriptor,
|
||||||
|
// which may result in "tart run" crashing because of NIO's fatal assertion on EBADF.
|
||||||
|
let vmSocket = try duplicateAndCloseConnection(vmConnection)
|
||||||
|
|
||||||
|
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmSocket) { childChannel in
|
||||||
childChannel.eventLoop.makeCompletedFuture {
|
childChannel.eventLoop.makeCompletedFuture {
|
||||||
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||||
wrappingChannelSynchronously: childChannel
|
wrappingChannelSynchronously: childChannel
|
||||||
@@ -104,4 +119,28 @@ class ControlSocket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func duplicateAndCloseConnection(_ connection: VZVirtioSocketConnection) throws -> CInt {
|
||||||
|
defer { connection.close() }
|
||||||
|
|
||||||
|
let fd = fcntl(connection.fileDescriptor, F_DUPFD_CLOEXEC, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
throw Errno(rawValue: errno)
|
||||||
|
}
|
||||||
|
|
||||||
|
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]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
|
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
|
||||||
private let byteCount: Int
|
private let byteCount: Int?
|
||||||
private let jsonValue: any Encodable
|
private let jsonValue: any Encodable
|
||||||
|
|
||||||
init<JSONValue: Encodable>(_ byteCount: Int, encodedAs: (Int) -> JSONValue) {
|
init<JSONValue: Encodable>(_ byteCount: Int?, encodedAs: (Int) -> JSONValue) {
|
||||||
self.byteCount = byteCount
|
self.byteCount = byteCount
|
||||||
self.jsonValue = encodedAs(byteCount)
|
self.jsonValue = byteCount.map(encodedAs)
|
||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
|
guard let byteCount else {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
let formatter = MeasurementFormatter()
|
let formatter = MeasurementFormatter()
|
||||||
formatter.unitOptions = .naturalScale
|
formatter.unitOptions = .naturalScale
|
||||||
formatter.unitStyle = .medium
|
formatter.unitStyle = .medium
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -111,27 +114,24 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
private let baseURL: URL
|
let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
let authenticationKeeper = AuthenticationKeeper()
|
let authenticationKeeper = AuthenticationKeeper()
|
||||||
|
|
||||||
var host: String? {
|
// Host with an optional port (e.g. "127.0.0.1:5000"), which is used for naming
|
||||||
guard let host = baseURL.host else { return nil }
|
// and credentials lookup. For Docker Hub it stays "docker.io", while baseURL
|
||||||
|
// points to registry-1.docker.io.
|
||||||
if let port = baseURL.port {
|
let host: String?
|
||||||
return "\(host):\(port)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
init(baseURL: URL,
|
init(baseURL: URL,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
|
host: String? = nil,
|
||||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
self.baseURL = baseURL
|
self.baseURL = baseURL
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
|
self.host = host ?? Registry.hostWithPort(of: baseURL)
|
||||||
self.credentialsProviders = credentialsProviders
|
self.credentialsProviders = credentialsProviders
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +142,9 @@ class Registry {
|
|||||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
var baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
|
|
||||||
guard let baseURL = baseURLComponents.url else {
|
guard var baseURL = baseURLComponents.url else {
|
||||||
var hint = ""
|
var hint = ""
|
||||||
|
|
||||||
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||||
@@ -154,7 +154,33 @@ class Registry {
|
|||||||
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
|
// Naming and credentials lookup use the host and port of the original URL,
|
||||||
|
// so it's "docker.io" for Docker Hub and "127.0.0.1:5000" for "127.0.0.1:05000"
|
||||||
|
let normalizedHost = Registry.hostWithPort(of: baseURL)
|
||||||
|
|
||||||
|
// Docker Hub serves its registry API from registry-1.docker.io, while docker.io,
|
||||||
|
// the host used in image names, redirects to Docker's website. URLSession follows
|
||||||
|
// these redirects, so we'd get an HTML page with HTTP 200 instead of an API
|
||||||
|
// response, which breaks pushing, pulling and "tart login" credentials validation.
|
||||||
|
//
|
||||||
|
// Host names are case insensitive, and only the host is replaced,
|
||||||
|
// so an explicit port like in "Docker.IO:443" is kept.
|
||||||
|
if baseURLComponents.host?.lowercased() == "docker.io" {
|
||||||
|
baseURLComponents.host = "registry-1.docker.io"
|
||||||
|
baseURL = baseURLComponents.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.init(baseURL: baseURL, namespace: namespace, host: normalizedHost, credentialsProviders: credentialsProviders)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hostWithPort(of url: URL) -> String? {
|
||||||
|
guard let host = url.host else { return nil }
|
||||||
|
|
||||||
|
if let port = url.port {
|
||||||
|
return "\(host):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
func ping() async throws {
|
func ping() async throws {
|
||||||
@@ -421,12 +447,8 @@ class Registry {
|
|||||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials() throws -> (String, String)? {
|
func lookupCredentials() throws -> (String, String)? {
|
||||||
var host = baseURL.host!
|
let host = self.host!
|
||||||
|
|
||||||
if let port = baseURL.port {
|
|
||||||
host += ":\(port)"
|
|
||||||
}
|
|
||||||
|
|
||||||
for provider in credentialsProviders {
|
for provider in credentialsProviders {
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -104,40 +104,42 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyboards() -> [VZKeyboardConfiguration] {
|
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
|
var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||||
if #available(macOS 14, *) {
|
if #available(macOS 14, *) {
|
||||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
devices.append(VZMacKeyboardConfiguration())
|
||||||
} else {
|
|
||||||
return [VZUSBKeyboardConfiguration()]
|
|
||||||
}
|
}
|
||||||
|
return devices
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
if #available(macOS 14, *) {
|
if #available(macOS 14, *) {
|
||||||
return [VZMacKeyboardConfiguration()]
|
return [VZMacKeyboardConfiguration()]
|
||||||
} else {
|
} else {
|
||||||
// fallback to the regular configuration
|
// fallback to the regular configuration
|
||||||
return keyboards()
|
return keyboards(noUSB: noUSB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
// Trackpad is only supported by guests starting with macOS Ventura
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
devices.append(VZMacTrackpadConfiguration())
|
||||||
|
return devices
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
// Only include the USB pointing device, not the trackpad
|
// Only include the USB pointing device, not the trackpad
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
return noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
if #available(macOS 14, *) {
|
if #available(macOS 14, *) {
|
||||||
return [VZMacTrackpadConfiguration()]
|
return [VZMacTrackpadConfiguration()]
|
||||||
} else {
|
} else {
|
||||||
// fallback to the regular configuration
|
// fallback to the regular configuration
|
||||||
return pointingDevices()
|
return pointingDevices(noUSB: noUSB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,16 +35,16 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyboards() -> [VZKeyboardConfiguration] {
|
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
[VZUSBKeyboardConfiguration()]
|
noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
// 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()
|
return pointingDevices(noUSB: noUSB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ protocol Platform: Codable {
|
|||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
func keyboards() -> [VZKeyboardConfiguration]
|
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
|
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol PlatformSuspendable: Platform {
|
protocol PlatformSuspendable: Platform {
|
||||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-19
@@ -49,6 +49,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
nested: Bool = false,
|
nested: Bool = false,
|
||||||
audio: Bool = true,
|
audio: Bool = true,
|
||||||
clipboard: Bool = true,
|
clipboard: Bool = true,
|
||||||
|
noUSBAccessories: Bool = false,
|
||||||
sync: VZDiskImageSynchronizationMode = .full,
|
sync: VZDiskImageSynchronizationMode = .full,
|
||||||
caching: VZDiskImageCachingMode? = nil,
|
caching: VZDiskImageCachingMode? = nil,
|
||||||
noTrackpad: Bool = false,
|
noTrackpad: Bool = false,
|
||||||
@@ -73,6 +74,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
nested: nested,
|
nested: nested,
|
||||||
audio: audio,
|
audio: audio,
|
||||||
clipboard: clipboard,
|
clipboard: clipboard,
|
||||||
|
noUSBAccessories: noUSBAccessories,
|
||||||
sync: sync,
|
sync: sync,
|
||||||
caching: caching,
|
caching: caching,
|
||||||
noTrackpad: noTrackpad,
|
noTrackpad: noTrackpad,
|
||||||
@@ -324,6 +326,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
nested: Bool = false,
|
nested: Bool = false,
|
||||||
audio: Bool = true,
|
audio: Bool = true,
|
||||||
clipboard: Bool = true,
|
clipboard: Bool = true,
|
||||||
|
noUSBAccessories: Bool = false,
|
||||||
sync: VZDiskImageSynchronizationMode = .full,
|
sync: VZDiskImageSynchronizationMode = .full,
|
||||||
caching: VZDiskImageCachingMode? = nil,
|
caching: VZDiskImageCachingMode? = nil,
|
||||||
noTrackpad: Bool = false,
|
noTrackpad: Bool = false,
|
||||||
@@ -364,25 +367,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.audioDevices = [soundDeviceConfiguration]
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
configureInputDevices(
|
||||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
configuration,
|
||||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
platform: vmConfig.platform,
|
||||||
} else {
|
suspendable: suspendable,
|
||||||
|
noUSBAccessories: noUSBAccessories,
|
||||||
if noKeyboard {
|
noTrackpad: noTrackpad,
|
||||||
configuration.keyboards = []
|
noPointer: noPointer,
|
||||||
} else {
|
noKeyboard: noKeyboard
|
||||||
configuration.keyboards = vmConfig.platform.keyboards()
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if noPointer {
|
|
||||||
configuration.pointingDevices = []
|
|
||||||
} else if noTrackpad {
|
|
||||||
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
|
|
||||||
} else {
|
|
||||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
configuration.networkDevices = network.attachments().map {
|
configuration.networkDevices = network.attachments().map {
|
||||||
@@ -460,6 +453,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return configuration
|
return configuration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func configureInputDevices(
|
||||||
|
_ configuration: VZVirtualMachineConfiguration,
|
||||||
|
platform: Platform,
|
||||||
|
suspendable: Bool = false,
|
||||||
|
noUSBAccessories: Bool = false,
|
||||||
|
noTrackpad: Bool = false,
|
||||||
|
noPointer: Bool = false,
|
||||||
|
noKeyboard: Bool = false
|
||||||
|
) {
|
||||||
|
if suspendable, let platformSuspendable = platform as? PlatformSuspendable {
|
||||||
|
configuration.keyboards = platformSuspendable.keyboardsSuspendable(noUSB: noUSBAccessories)
|
||||||
|
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable(noUSB: noUSBAccessories)
|
||||||
|
} else {
|
||||||
|
configuration.keyboards = noKeyboard ? [] : platform.keyboards(noUSB: noUSBAccessories)
|
||||||
|
|
||||||
|
if noPointer {
|
||||||
|
configuration.pointingDevices = []
|
||||||
|
} else if noTrackpad {
|
||||||
|
configuration.pointingDevices = platform.pointingDevicesSimplified(noUSB: noUSBAccessories)
|
||||||
|
} else {
|
||||||
|
configuration.pointingDevices = platform.pointingDevices(noUSB: noUSBAccessories)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
func guestDidStop(_ virtualMachine: VZVirtualMachine) {
|
||||||
print("guest has stopped the virtual machine")
|
print("guest has stopped the virtual machine")
|
||||||
sema.signal()
|
sema.signal()
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ struct VMConfig: Codable {
|
|||||||
|
|
||||||
func save(toURL: URL) throws {
|
func save(toURL: URL) throws {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = .prettyPrinted
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
try encoder.encode(self).write(to: toURL)
|
try encoder.encode(self).write(to: toURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -469,10 +469,6 @@ struct VMDirectory: Prunable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func diskSizeGB() throws -> Int {
|
|
||||||
try diskSizeBytes() / 1000 / 1000 / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
func markExplicitlyPulled() {
|
func markExplicitlyPulled() {
|
||||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")!))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,140 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class CommandBehaviorTests: XCTestCase {
|
final class CommandBehaviorTests: XCTestCase {
|
||||||
|
func testListSurvivesUnavailableDiskCapacity() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let previousPath = try installUnavailableDiskutil()
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
|
||||||
|
let local = try VMStorageLocal()
|
||||||
|
let oci = try VMStorageOCI()
|
||||||
|
for (name, diskFormat) in [("unavailable", DiskImageFormat.asif), ("healthy", .raw)] {
|
||||||
|
let remoteName = try RemoteName("example.com/org/\(name):latest")
|
||||||
|
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()))
|
||||||
|
// The diskutil stub simulates a locked ASIF disk without needing a running VM.
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(
|
||||||
|
atPath: vmDir.diskURL.path,
|
||||||
|
contents: Data(repeating: 0, count: 4096)
|
||||||
|
))
|
||||||
|
if diskFormat == .asif {
|
||||||
|
XCTAssertThrowsError(try vmDir.diskSizeBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for sourceArguments in [[], ["--source", "local"], ["--source", "oci"]] {
|
||||||
|
let json = try await commandOutput(List.self, sourceArguments + ["--format", "json"])
|
||||||
|
let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]])
|
||||||
|
XCTAssertEqual(rows.count, sourceArguments.isEmpty ? 4 : 2)
|
||||||
|
for row in rows {
|
||||||
|
let name = try XCTUnwrap(row["Name"] as? String)
|
||||||
|
if name.contains("unavailable") {
|
||||||
|
XCTAssertTrue(row["Disk"] is NSNull)
|
||||||
|
} else {
|
||||||
|
XCTAssertEqual(row["Disk"] as? Int, 0)
|
||||||
|
}
|
||||||
|
XCTAssertEqual(row["State"] as? String, "stopped")
|
||||||
|
XCTAssertEqual(row["Running"] as? Bool, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = try await commandOutput(List.self, sourceArguments)
|
||||||
|
XCTAssertTrue(text.contains("unavailable"))
|
||||||
|
XCTAssertTrue(text.contains("healthy"))
|
||||||
|
XCTAssertTrue(text.contains("-"))
|
||||||
|
|
||||||
|
let quiet = try await commandOutput(List.self, sourceArguments + ["--quiet"])
|
||||||
|
XCTAssertEqual(quiet.split(separator: "\n").map(String.init), rows.compactMap { $0["Name"] as? String })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let previousPath = try installUnavailableDiskutil()
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
|
||||||
|
let vmDir = try VMStorageLocal().create("unavailable")
|
||||||
|
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(repeating: 0, count: 4096)
|
||||||
|
))
|
||||||
|
XCTAssertThrowsError(try vmDir.diskSizeBytes())
|
||||||
|
|
||||||
|
let json = try await commandOutput(Get.self, ["unavailable", "--format", "json"])
|
||||||
|
let info = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
|
||||||
|
XCTAssertTrue(info["Disk"] is NSNull)
|
||||||
|
XCTAssertEqual(info["DiskFormat"] as? String, "asif")
|
||||||
|
XCTAssertEqual(info["State"] as? String, "stopped")
|
||||||
|
|
||||||
|
let text = try await commandOutput(Get.self, ["unavailable"])
|
||||||
|
XCTAssertTrue(text.contains("asif"))
|
||||||
|
XCTAssertTrue(text.contains("-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNoUSBAccessoriesDoesNotEnableSuspendable() throws {
|
||||||
|
try withTemporaryTartHome {
|
||||||
|
let vmDir = try VMStorageLocal().create("no-usb-accessories")
|
||||||
|
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()))
|
||||||
|
|
||||||
|
let command = try Run.parseAsRoot(["no-usb-accessories", "--no-usb-accessories"]) as! Run
|
||||||
|
|
||||||
|
XCTAssertTrue(command.noUSBAccessories)
|
||||||
|
XCTAssertFalse(command.suspendable)
|
||||||
|
XCTAssertFalse(command.noAudio)
|
||||||
|
XCTAssertFalse(command.noGraphics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testStandaloneDeleteDoesNotInitializeContentStore() throws {
|
func testStandaloneDeleteDoesNotInitializeContentStore() throws {
|
||||||
try withTemporaryTartHome {
|
try withTemporaryTartHome {
|
||||||
let vmDir = try VMStorageLocal().create("standalone")
|
let vmDir = try VMStorageLocal().create("standalone")
|
||||||
@@ -21,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")
|
||||||
@@ -51,6 +245,22 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -98,6 +308,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(),
|
||||||
@@ -107,6 +357,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,
|
||||||
@@ -114,6 +372,45 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func installUnavailableDiskutil(beforeFailure: String = "") throws -> String? {
|
||||||
|
let binDirectory = try temporaryDirectory()
|
||||||
|
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
||||||
|
let script = """
|
||||||
|
#!/bin/sh
|
||||||
|
\(beforeFailure)
|
||||||
|
echo 'Resource temporarily unavailable' >&2
|
||||||
|
exit 1
|
||||||
|
"""
|
||||||
|
try script.write(to: diskutilURL, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: diskutilURL.path)
|
||||||
|
let previousPath = ProcessInfo.processInfo.environment["PATH"]
|
||||||
|
setenv("PATH", binDirectory.path, 1)
|
||||||
|
return previousPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func commandOutput<Command: AsyncParsableCommand>(
|
||||||
|
_ commandType: Command.Type,
|
||||||
|
_ arguments: [String]
|
||||||
|
) async throws -> String {
|
||||||
|
let outputURL = try temporaryDirectory().appendingPathComponent("stdout")
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: outputURL.path, contents: nil))
|
||||||
|
let output = try FileHandle(forWritingTo: outputURL)
|
||||||
|
defer { try? output.close() }
|
||||||
|
|
||||||
|
fflush(stdout)
|
||||||
|
let savedStdout = dup(STDOUT_FILENO)
|
||||||
|
defer {
|
||||||
|
fflush(stdout)
|
||||||
|
dup2(savedStdout, STDOUT_FILENO)
|
||||||
|
close(savedStdout)
|
||||||
|
}
|
||||||
|
dup2(output.fileDescriptor, STDOUT_FILENO)
|
||||||
|
var command = try Command.parseAsRoot(arguments) as! Command
|
||||||
|
try await command.run()
|
||||||
|
fflush(stdout)
|
||||||
|
return try String(contentsOf: outputURL, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
private func withTemporaryTartHome(_ body: () throws -> Void) throws {
|
private func withTemporaryTartHome(_ body: () throws -> Void) throws {
|
||||||
let home = try temporaryDirectory()
|
let home = try temporaryDirectory()
|
||||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class HumanReadableByteCountTests: XCTestCase {
|
final class HumanReadableByteCountTests: XCTestCase {
|
||||||
|
func testUnknownByteCount() throws {
|
||||||
|
let unknown = HumanReadableByteCount(nil) { $0 / 1000 / 1000 / 1000 }
|
||||||
|
|
||||||
|
XCTAssertEqual(unknown.description, "-")
|
||||||
|
XCTAssertEqual(String(data: try JSONEncoder().encode(unknown), encoding: .utf8), "null")
|
||||||
|
}
|
||||||
|
|
||||||
func testTextAndJSONRepresentations() throws {
|
func testTextAndJSONRepresentations() throws {
|
||||||
let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 }
|
let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 }
|
||||||
let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }
|
let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import Virtualization
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class InputDeviceConfigurationTests: XCTestCase {
|
||||||
|
func testLinuxUSBInputsCanBeDisabled() {
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
|
VM.configureInputDevices(configuration, platform: Linux())
|
||||||
|
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||||
|
XCTAssertTrue(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration })
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration })
|
||||||
|
|
||||||
|
VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true)
|
||||||
|
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.isEmpty)
|
||||||
|
|
||||||
|
VM.configureInputDevices(configuration, platform: Linux(), noUSBAccessories: true, noTrackpad: true)
|
||||||
|
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
func testMacOS13RetainsItsNativeTrackpad() {
|
||||||
|
let platform = MacInputPlatform(nativeKeyboard: false)
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
|
VM.configureInputDevices(configuration, platform: platform)
|
||||||
|
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 2)
|
||||||
|
|
||||||
|
VM.configureInputDevices(configuration, platform: platform, noUSBAccessories: true)
|
||||||
|
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMacOS14RetainsBothNativeInputs() throws {
|
||||||
|
guard #available(macOS 14, *) else {
|
||||||
|
throw XCTSkip("Mac keyboards require macOS 14")
|
||||||
|
}
|
||||||
|
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
VM.configureInputDevices(configuration, platform: MacInputPlatform(nativeKeyboard: true), noUSBAccessories: true)
|
||||||
|
|
||||||
|
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||||
|
XCTAssertTrue(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration })
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInputFlagsStillSelectTheExpectedDevices() throws {
|
||||||
|
guard #available(macOS 14, *) else {
|
||||||
|
throw XCTSkip("Mac keyboards require macOS 14")
|
||||||
|
}
|
||||||
|
|
||||||
|
let platform = MacInputPlatform(nativeKeyboard: true)
|
||||||
|
for noUSBAccessories in [false, true] {
|
||||||
|
for noKeyboard in [false, true] {
|
||||||
|
for noPointer in [false, true] {
|
||||||
|
for noTrackpad in [false, true] {
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
VM.configureInputDevices(
|
||||||
|
configuration,
|
||||||
|
platform: platform,
|
||||||
|
noUSBAccessories: noUSBAccessories,
|
||||||
|
noTrackpad: noTrackpad,
|
||||||
|
noPointer: noPointer,
|
||||||
|
noKeyboard: noKeyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(configuration.keyboards.contains { $0 is VZUSBKeyboardConfiguration }, !noUSBAccessories && !noKeyboard)
|
||||||
|
XCTAssertEqual(configuration.keyboards.contains { $0 is VZMacKeyboardConfiguration }, !noKeyboard)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZUSBScreenCoordinatePointingDeviceConfiguration }, !noUSBAccessories && !noPointer)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration }, !noPointer && !noTrackpad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSuspendableFallbackCannotReintroduceUSBInputs() {
|
||||||
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
let platform = MacInputPlatform(nativeKeyboard: false)
|
||||||
|
VM.configureInputDevices(configuration, platform: platform, suspendable: true)
|
||||||
|
XCTAssertEqual(configuration.keyboards.count, 1)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 2)
|
||||||
|
|
||||||
|
VM.configureInputDevices(
|
||||||
|
configuration,
|
||||||
|
platform: platform,
|
||||||
|
suspendable: true,
|
||||||
|
noUSBAccessories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertTrue(configuration.keyboards.isEmpty)
|
||||||
|
XCTAssertEqual(configuration.pointingDevices.count, 1)
|
||||||
|
XCTAssertTrue(configuration.pointingDevices.contains { $0 is VZMacTrackpadConfiguration })
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
// Model macOS 13 and 14 input availability without requiring a second host.
|
||||||
|
private struct MacInputPlatform: PlatformSuspendable {
|
||||||
|
var nativeKeyboard: Bool
|
||||||
|
|
||||||
|
func os() -> OS { .darwin }
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
try Linux().bootLoader(nvramURL: nvramURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
|
try Linux().platform(nvramURL: nvramURL, needsNestedVirtualization: needsNestedVirtualization)
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
Linux().graphicsDevice(vmConfig: vmConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
|
var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||||
|
if nativeKeyboard, #available(macOS 14, *) {
|
||||||
|
devices.append(VZMacKeyboardConfiguration())
|
||||||
|
}
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
|
var devices: [VZPointingDeviceConfiguration] = noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
devices.append(VZMacTrackpadConfiguration())
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
|
noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||||
|
if nativeKeyboard, #available(macOS 14, *) {
|
||||||
|
return [VZMacKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
return keyboards(noUSB: noUSB)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||||
|
nativeKeyboard ? [VZMacTrackpadConfiguration()] : pointingDevices(noUSB: noUSB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -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,88 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class RegistryHostTests: XCTestCase {
|
||||||
|
func testDockerHub() throws {
|
||||||
|
let credentialsProvider = RecordingCredentialsProvider()
|
||||||
|
let registry = try Registry(host: "docker.io", namespace: "org/repo",
|
||||||
|
credentialsProviders: [credentialsProvider])
|
||||||
|
|
||||||
|
// docker.io redirects to Docker's website, so the API
|
||||||
|
// requests should go to registry-1.docker.io instead
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/"))
|
||||||
|
|
||||||
|
// ...while naming and credentials lookup should still use the host specified by the user
|
||||||
|
XCTAssertEqual(registry.host, "docker.io")
|
||||||
|
XCTAssertNil(try registry.lookupCredentials())
|
||||||
|
XCTAssertEqual(credentialsProvider.requestedHosts, ["docker.io"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDockerHubIsMatchedCaseInsensitively() throws {
|
||||||
|
let credentialsProvider = RecordingCredentialsProvider()
|
||||||
|
let registry = try Registry(host: "Docker.IO", namespace: "org/repo",
|
||||||
|
credentialsProviders: [credentialsProvider])
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io/v2/"))
|
||||||
|
XCTAssertEqual(registry.host, "Docker.IO")
|
||||||
|
XCTAssertNil(try registry.lookupCredentials())
|
||||||
|
XCTAssertEqual(credentialsProvider.requestedHosts, ["Docker.IO"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDockerHubWithExplicitPort() throws {
|
||||||
|
for host in ["docker.io:443", "DOCKER.IO:443"] {
|
||||||
|
let registry = try Registry(host: host, namespace: "org/repo")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "https://registry-1.docker.io:443/v2/"))
|
||||||
|
XCTAssertEqual(registry.host, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOtherHostsAreUnchanged() throws {
|
||||||
|
for host in ["ghcr.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com", "127.0.0.1:8080"] {
|
||||||
|
let registry = try Registry(host: host, namespace: "org/repo")
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "https://\(host)/v2/"))
|
||||||
|
XCTAssertEqual(registry.host, host)
|
||||||
|
}
|
||||||
|
|
||||||
|
let registry = try Registry(host: "127.0.0.1:5000", namespace: "org/repo", insecure: true)
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:5000/v2/"))
|
||||||
|
XCTAssertEqual(registry.host, "127.0.0.1:5000")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHostPortIsNormalized() throws {
|
||||||
|
// Credentials stored for "127.0.0.1:5000" should still be found
|
||||||
|
// when the port is written with a leading zero
|
||||||
|
let credentialsProvider = RecordingCredentialsProvider(credentials: ["127.0.0.1:5000": ("user", "password")])
|
||||||
|
let registry = try Registry(host: "127.0.0.1:05000", namespace: "org/repo", insecure: true,
|
||||||
|
credentialsProviders: [credentialsProvider])
|
||||||
|
|
||||||
|
XCTAssertEqual(registry.baseURL, URL(string: "http://127.0.0.1:05000/v2/"))
|
||||||
|
XCTAssertEqual(registry.host, "127.0.0.1:5000")
|
||||||
|
|
||||||
|
let (user, password) = try XCTUnwrap(registry.lookupCredentials())
|
||||||
|
XCTAssertEqual(user, "user")
|
||||||
|
XCTAssertEqual(password, "password")
|
||||||
|
XCTAssertEqual(credentialsProvider.requestedHosts, ["127.0.0.1:5000"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate class RecordingCredentialsProvider: CredentialsProvider {
|
||||||
|
let userFriendlyName = "recording credentials provider"
|
||||||
|
|
||||||
|
let credentials: [String: (String, String)]
|
||||||
|
var requestedHosts: [String] = []
|
||||||
|
|
||||||
|
init(credentials: [String: (String, String)] = [:]) {
|
||||||
|
self.credentials = credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
|
requestedHosts.append(host)
|
||||||
|
|
||||||
|
return credentials[host]
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(host: String, user: String, password: String) throws {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
-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)
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExportOverwriteConfirmation(t *testing.T) {
|
||||||
|
for _, tt := range []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
overwrite bool
|
||||||
|
}{
|
||||||
|
{name: "EOF"},
|
||||||
|
{name: "empty line", input: "\n"},
|
||||||
|
{name: "no", input: "no\n"},
|
||||||
|
{name: "yes", input: "yes\n", overwrite: true},
|
||||||
|
} {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("TART_HOME", home)
|
||||||
|
t.Setenv("TART_NO_AUTO_PRUNE", "1")
|
||||||
|
createSyntheticVM(t, home, "source", "92:81:b5:ab:39:37")
|
||||||
|
|
||||||
|
directory := t.TempDir()
|
||||||
|
destination := filepath.Join(directory, "source.tvm")
|
||||||
|
original := []byte("existing archive")
|
||||||
|
if err := os.WriteFile(destination, original, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(t.Context(), "tart", "export", "source")
|
||||||
|
cmd.Dir = directory
|
||||||
|
cmd.Stdin = strings.NewReader(tt.input)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("export: %v: %s", err, output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(output), "are you sure you want to overwrite it?") {
|
||||||
|
t.Fatalf("expected overwrite confirmation: %s", output)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(output), "exporting...") != tt.overwrite {
|
||||||
|
t.Fatalf("unexpected export behavior: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := os.ReadFile(destination)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
changed := !bytes.Equal(original, current)
|
||||||
|
if changed != tt.overwrite {
|
||||||
|
t.Fatalf("destination changed = %t, want %t", changed, tt.overwrite)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
|
||||||
@@ -19,7 +20,7 @@ import (
|
|||||||
|
|
||||||
func TestOpenTelemetry(t *testing.T) {
|
func TestOpenTelemetry(t *testing.T) {
|
||||||
// Start a mock OpenTelemetry collector server
|
// Start a mock OpenTelemetry collector server
|
||||||
var traces []*tracepkg.ExportTraceServiceRequest
|
traces := make(chan *tracepkg.ExportTraceServiceRequest, 1)
|
||||||
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
var trace tracepkg.ExportTraceServiceRequest
|
var trace tracepkg.ExportTraceServiceRequest
|
||||||
@@ -43,7 +44,11 @@ func TestOpenTelemetry(t *testing.T) {
|
|||||||
"we do not support %q yet", request.Header.Get("Content-Type"))
|
"we do not support %q yet", request.Header.Get("Content-Type"))
|
||||||
}
|
}
|
||||||
|
|
||||||
traces = append(traces, &trace)
|
select {
|
||||||
|
case traces <- &trace:
|
||||||
|
default:
|
||||||
|
t.Error("received an unexpected additional trace")
|
||||||
|
}
|
||||||
|
|
||||||
var response tracepkg.ExportTraceServiceResponse
|
var response tracepkg.ExportTraceServiceResponse
|
||||||
|
|
||||||
@@ -54,6 +59,7 @@ func TestOpenTelemetry(t *testing.T) {
|
|||||||
_, err = writer.Write(responseBytes)
|
_, err = writer.Write(responseBytes)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}))
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
// Start a "tart list" command
|
// Start a "tart list" command
|
||||||
serverURL, err := url.Parse(server.URL)
|
serverURL, err := url.Parse(server.URL)
|
||||||
@@ -67,9 +73,16 @@ func TestOpenTelemetry(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Ensure that the mock OpenTelemetry collector received a trace from "tart list"
|
// Ensure that the mock OpenTelemetry collector received a trace from "tart list"
|
||||||
require.Len(t, traces, 1)
|
var trace *tracepkg.ExportTraceServiceRequest
|
||||||
|
select {
|
||||||
|
case trace = <-traces:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for OpenTelemetry trace")
|
||||||
|
}
|
||||||
|
server.Close()
|
||||||
|
require.Empty(t, traces, "received an unexpected additional trace")
|
||||||
|
|
||||||
resourceSpans := traces[0].GetResourceSpans()
|
resourceSpans := trace.GetResourceSpans()
|
||||||
require.Len(t, resourceSpans, 1)
|
require.Len(t, resourceSpans, 1)
|
||||||
|
|
||||||
// Ensure that service name and version resources are set
|
// Ensure that service name and version resources are set
|
||||||
|
|||||||
Reference in New Issue
Block a user