mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1c6bd315 | ||
|
|
27d3e2c5da | ||
|
|
a80ec74a42 | ||
|
|
8ac52501c3 | ||
|
|
bb4acb2468 | ||
|
|
65aea029ab | ||
|
|
4e58a2a0b9 | ||
|
|
f8ce0f9acb | ||
|
|
6fff37f0e0 | ||
|
|
89017ff0b3 | ||
|
|
acaf3ca7ef |
@@ -5,12 +5,15 @@ set -eu
|
||||
ARCH="$1"
|
||||
SCRATCH_PATH=".build/$ARCH"
|
||||
OUTPUT_PATH=".build/prebuilt/$ARCH"
|
||||
SWIFT_COMPATIBILITY_LIBRARY="$(dirname "$(xcrun --find swiftc)")/../lib/swift-6.2/macosx/libswiftCompatibilitySpan.dylib"
|
||||
|
||||
swift build \
|
||||
--build-system swiftbuild \
|
||||
--scratch-path "$SCRATCH_PATH" \
|
||||
--arch "$ARCH" \
|
||||
--configuration release \
|
||||
-Xlinker -weak_library \
|
||||
-Xlinker "$SWIFT_COMPATIBILITY_LIBRARY" \
|
||||
--product tart
|
||||
|
||||
BIN_PATH=$(swift build \
|
||||
|
||||
@@ -37,7 +37,7 @@ struct Export: AsyncParsableCommand {
|
||||
func userWantsOverwrite(_ filename: String) -> Bool {
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ struct Get: AsyncParsableCommand {
|
||||
OS: vmConfig.os,
|
||||
CPU: vmConfig.cpuCount,
|
||||
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,
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) {
|
||||
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
||||
|
||||
@@ -20,7 +20,7 @@ struct IP: AsyncParsableCommand {
|
||||
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
||||
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
|
||||
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
|
||||
}
|
||||
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) {
|
||||
return ip
|
||||
}
|
||||
|
||||
@@ -42,7 +42,8 @@ struct List: AsyncParsableCommand {
|
||||
try VMInfo(
|
||||
Source: "local",
|
||||
Name: name,
|
||||
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 },
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
@@ -56,7 +57,7 @@ struct List: AsyncParsableCommand {
|
||||
try VMInfo(
|
||||
Source: "OCI",
|
||||
Name: name,
|
||||
Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||
Running: vmDir.running(),
|
||||
|
||||
@@ -90,6 +90,9 @@ struct Run: AsyncParsableCommand {
|
||||
@Flag(help: "Disable audio pass-through to host.")
|
||||
var noAudio: Bool = false
|
||||
|
||||
@Flag(help: "Disable USB accessories.")
|
||||
var noUSBAccessories: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"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."))
|
||||
@@ -470,6 +473,7 @@ struct Run: AsyncParsableCommand {
|
||||
nested: nested,
|
||||
audio: !noAudio,
|
||||
clipboard: !noClipboard,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
sync: VZDiskImageSynchronizationMode(diskOptions.syncModeRaw),
|
||||
caching: VZDiskImageCachingMode(diskOptions.cachingModeRaw),
|
||||
noTrackpad: noTrackpad,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import System
|
||||
import Virtualization
|
||||
import Network
|
||||
import os.log
|
||||
import NIO
|
||||
@@ -72,7 +75,13 @@ class ControlSocket {
|
||||
|
||||
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 {
|
||||
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||
wrappingChannelSynchronously: childChannel
|
||||
@@ -104,4 +113,15 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
struct HumanReadableByteCount: Encodable, CustomStringConvertible {
|
||||
private let byteCount: Int
|
||||
private let byteCount: Int?
|
||||
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.jsonValue = encodedAs(byteCount)
|
||||
self.jsonValue = byteCount.map(encodedAs)
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard let byteCount else {
|
||||
return "-"
|
||||
}
|
||||
|
||||
let formatter = MeasurementFormatter()
|
||||
formatter.unitOptions = .naturalScale
|
||||
formatter.unitStyle = .medium
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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) {
|
||||
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() {
|
||||
mac[index] = UInt8(component, radix: 16)!
|
||||
guard let byte = UInt8(component, radix: 16) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
mac[index] = byte
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,27 +111,24 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let baseURL: URL
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
|
||||
if let port = baseURL.port {
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
|
||||
return host
|
||||
}
|
||||
// Host with an optional port (e.g. "127.0.0.1:5000"), which is used for naming
|
||||
// and credentials lookup. For Docker Hub it stays "docker.io", while baseURL
|
||||
// points to registry-1.docker.io.
|
||||
let host: String?
|
||||
|
||||
init(baseURL: URL,
|
||||
namespace: String,
|
||||
host: String? = nil,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
self.baseURL = baseURL
|
||||
self.namespace = namespace
|
||||
self.host = host ?? Registry.hostWithPort(of: baseURL)
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
@@ -142,9 +139,9 @@ class Registry {
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
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 = ""
|
||||
|
||||
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||
@@ -154,7 +151,33 @@ class Registry {
|
||||
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 {
|
||||
@@ -421,12 +444,8 @@ class Registry {
|
||||
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||
}
|
||||
|
||||
private func lookupCredentials() throws -> (String, String)? {
|
||||
var host = baseURL.host!
|
||||
|
||||
if let port = baseURL.port {
|
||||
host += ":\(port)"
|
||||
}
|
||||
func lookupCredentials() throws -> (String, String)? {
|
||||
let host = self.host!
|
||||
|
||||
for provider in credentialsProviders {
|
||||
do {
|
||||
|
||||
@@ -104,40 +104,42 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
var devices: [VZKeyboardConfiguration] = noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
devices.append(VZMacKeyboardConfiguration())
|
||||
}
|
||||
return devices
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// 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
|
||||
[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
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
return noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
return pointingDevices(noUSB: noUSB)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,16 @@ struct Linux: Platform {
|
||||
return result
|
||||
}
|
||||
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
[VZUSBKeyboardConfiguration()]
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration] {
|
||||
noUSB ? [] : [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
noUSB ? [] : [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration] {
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration] {
|
||||
// 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 platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified() -> [VZPointingDeviceConfiguration]
|
||||
func keyboards(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||
func pointingDevices(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
func pointingDevicesSimplified(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
func pointingDevicesSuspendable(noUSB: Bool) -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable(noUSB: Bool) -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -8,21 +8,21 @@ extension URL {
|
||||
}
|
||||
|
||||
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||
let modificationDate = attrs.contentAccessDate!
|
||||
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
let times = [accessDate.asTimespec(), timespec(tv_sec: 0, tv_nsec: Int(UTIME_OMIT))]
|
||||
let ret = utimensat(AT_FDCWD, path, times, 0)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimensat(2) failed: \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
func asTimeval() -> timeval {
|
||||
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
||||
func asTimespec() -> timespec {
|
||||
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,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
noUSBAccessories: Bool = false,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
@@ -73,6 +74,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nested: nested,
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
sync: sync,
|
||||
caching: caching,
|
||||
noTrackpad: noTrackpad,
|
||||
@@ -324,6 +326,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
nested: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
noUSBAccessories: Bool = false,
|
||||
sync: VZDiskImageSynchronizationMode = .full,
|
||||
caching: VZDiskImageCachingMode? = nil,
|
||||
noTrackpad: Bool = false,
|
||||
@@ -364,25 +367,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
|
||||
if noKeyboard {
|
||||
configuration.keyboards = []
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
}
|
||||
|
||||
if noPointer {
|
||||
configuration.pointingDevices = []
|
||||
} else if noTrackpad {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevicesSimplified()
|
||||
} else {
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
}
|
||||
configureInputDevices(
|
||||
configuration,
|
||||
platform: vmConfig.platform,
|
||||
suspendable: suspendable,
|
||||
noUSBAccessories: noUSBAccessories,
|
||||
noTrackpad: noTrackpad,
|
||||
noPointer: noPointer,
|
||||
noKeyboard: noKeyboard
|
||||
)
|
||||
|
||||
// Networking
|
||||
configuration.networkDevices = network.attachments().map {
|
||||
@@ -460,6 +453,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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) {
|
||||
print("guest has stopped the virtual machine")
|
||||
sema.signal()
|
||||
|
||||
@@ -99,7 +99,7 @@ struct VMConfig: Codable {
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
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() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ class VMStorageHelper {
|
||||
|
||||
extension NSError {
|
||||
func isFileNotFound() -> Bool {
|
||||
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
||||
return self.domain == NSCocoaErrorDomain &&
|
||||
(self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import XCTest
|
||||
import Network
|
||||
@testable import tart
|
||||
|
||||
final class BootptabTests: XCTestCase {
|
||||
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,101 @@ import XCTest
|
||||
@testable import tart
|
||||
|
||||
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 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 {
|
||||
try withTemporaryTartHome {
|
||||
let vmDir = try VMStorageLocal().create("standalone")
|
||||
@@ -21,6 +116,12 @@ 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 testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
||||
try await withTemporaryTartHome {
|
||||
let vmDir = try VMStorageLocal().create("stacked")
|
||||
@@ -114,6 +215,44 @@ final class CommandBehaviorTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
private func installUnavailableDiskutil() throws -> String? {
|
||||
let binDirectory = try temporaryDirectory()
|
||||
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
||||
let script = """
|
||||
#!/bin/sh
|
||||
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 {
|
||||
let home = try temporaryDirectory()
|
||||
let previousHome = ProcessInfo.processInfo.environment["TART_HOME"]
|
||||
|
||||
@@ -3,6 +3,13 @@ import XCTest
|
||||
@testable import tart
|
||||
|
||||
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 {
|
||||
let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 }
|
||||
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
|
||||
@@ -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,21 +2,29 @@ import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class URLAccessDateTests: XCTestCase {
|
||||
func testGetAndSetAccessTime() throws {
|
||||
func testUpdateAccessDatePreservesModificationDate() throws {
|
||||
// Create a temporary file
|
||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
||||
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
|
||||
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
|
||||
// Ensure its access date is different from our desired access date
|
||||
let accessDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||
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
|
||||
try tmpFile.updateAccessDate(arbitraryDate)
|
||||
try tmpFile.updateAccessDate(accessDate)
|
||||
|
||||
// Ensure the access date has changed to our value
|
||||
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).
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
|
||||
func TestOpenTelemetry(t *testing.T) {
|
||||
// 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) {
|
||||
var trace tracepkg.ExportTraceServiceRequest
|
||||
@@ -43,7 +44,11 @@ func TestOpenTelemetry(t *testing.T) {
|
||||
"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
|
||||
|
||||
@@ -54,6 +59,7 @@ func TestOpenTelemetry(t *testing.T) {
|
||||
_, err = writer.Write(responseBytes)
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// Start a "tart list" command
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
@@ -67,9 +73,16 @@ func TestOpenTelemetry(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// 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)
|
||||
|
||||
// Ensure that service name and version resources are set
|
||||
|
||||
Reference in New Issue
Block a user