Compare commits

...
20 Commits
Author SHA1 Message Date
12 05edeac562 Ignore commented-out bootptab reservations (#1358) 2026-09-30 11:32:27 +00:00
edi-oai 917c0b51ed --net-host: use VZVmnetNetworkDeviceAttachment with VMNET_HOST_MODE (#1356) 2026-09-28 22:36:09 +01:00
RKSandYibo Zhuang e3b4f71f77 fix(control-socket): recover from transient accept errors (#1351)
* fix(control-socket): recover from transient accept errors

Keep the control socket alive when Darwin reports a transient fcntl failure while accepting a client connection.

Refs #1346

* test(control-socket): use synchronous pipeline lookup

* test(control-socket): query handler on event loop

* fix(control-socket): preserve channel backpressure

* test(control-socket): exercise accept error recovery

* test(control-socket): verify accepts continue after transient errors

Check that the real server inbound stream yields a client connection after
each simulated accept error. Keep the read-routing and unrelated-error
checks, bound the wait, and close the test connections.

Adapted from the regression test suggested in:
https://github.com/openai/tart/pull/1351#issuecomment-5851285933

---------

Co-authored-by: Yibo Zhuang <yzhuang@openai.com>
2026-09-28 13:22:06 -07:00
RKS e92c3b900f Protect existing VMs during clone (#1331)
* fix(clone): protect existing VMs from overwrite

* fix(clone): preserve incomplete destination directories
2026-09-28 12:58:03 -07:00
edi-oai 5c1c6bd315 tart ip: read /etc/bootptab in addition to /var/db/dhcpd_leases (#1355) 2026-09-28 16:49:06 +01:00
RKS 27d3e2c5da Weak-link Swift compatibility library in Tart releases (#1339)
* Bundle Swift compatibility libraries in Tart releases

* fix(release): weak-link Swift compatibility library

* fix(release): weak-link from Xcode 27 library path
2026-09-26 08:48:02 -07:00
Minh Vu a80ec74a42 Preserve modification date when updating access time (#1292) 2026-09-26 08:45:14 -07:00
RKS 8ac52501c3 fix(storage): preserve running VM delete errors (#1350)
* fix(storage): preserve running VM delete errors

Do not reinterpret RuntimeError.VMIsRunning as a missing VM when the storage wrapper bridges errors through NSError.

Refs #1345

* test(storage): initialize running VM lock file

* test(storage): hold VM lock in a child process

* fix(storage): narrow file-not-found error matching

* test(storage): use Swift error-domain regression coverage
2026-09-26 08:40:20 -07:00
Yoshimasa Niwa bb4acb2468 Fix listing VMs when disk capacity is unavailable (#1349)
* Allow HumanReadableByteCount to represent an unknown byte count

Some byte counts, such as the capacity of an ASIF disk image, can't
always be determined. Accept an optional byte count and render an
unknown value as "-" in text output and as null in JSON output.

* Fix listing and getting VMs when disk capacity is unavailable

For ASIF disk images, the disk capacity is read with "diskutil image
info". The command fails with "Resource temporarily unavailable" while
a running VM holds the disk image open. As a result, "tart list" and
"tart get" fail entirely when any such VM exists.

Treat the disk capacity as unknown when it can't be determined, so
that both commands still show the remaining information.

Fixes #1344

* Remove unused VMDirectory.diskSizeGB()

The method was added together with diskSizeBytes() but has never been
used. "tart list" and "tart get" use diskSizeBytes() directly.
2026-09-25 17:08:34 -07:00
om singhal 65aea029ab Use registry-1.docker.io for Docker Hub's docker.io host (#1332)
* Use registry-1.docker.io for Docker Hub's docker.io host

docker.io doesn't serve the registry API: https://docker.io/v2/ redirects
to https://www.docker.com/, which URLSession follows, getting back an HTML
page with HTTP 200. As a result, pushing fails with
UnexpectedHTTPStatusCode("pushing blob (POST)", 200, ...), pulling fails
to parse the manifest and "tart login docker.io" accepts any credentials,
because ping() never gets an authentication challenge.

Send the API requests for docker.io to registry-1.docker.io instead, while
still using docker.io for the pushed image names and for the credentials
lookup, so that credentials saved with "tart login docker.io" keep working.

Fixes #1275

* Match docker.io case insensitively

* Recognize Docker Hub with an explicit port

* Parse the registry host once and keep it normalized

Using the host exactly as specified for naming and credentials lookup
changed the behavior for other registries too. For example,
"127.0.0.1:05000" used to find credentials stored for "127.0.0.1:5000",
but didn't anymore.

Parse the URL once instead, take the normalized host and port from it
like before, and only replace the URL's host with registry-1.docker.io
for Docker Hub.
2026-09-24 14:00:30 -07:00
12 4e58a2a0b9 Fix export overwrite confirmation on EOF (#1342) 2026-09-23 15:10:01 -07:00
Yibo Zhuang f8ce0f9acb Add an option to disable USB accessories (#1338)
* Add an option to disable USB accessories

* Select USB accessories in platform input factories
2026-09-23 12:14:39 -07:00
Yibo Zhuang 6fff37f0e0 Wait for collector in OpenTelemetry integration test (#1335) 2026-09-21 09:10:53 -07:00
Brendan Shanks 89017ff0b3 VMConfig: Save JSON with sorted keys (#1326) 2026-09-16 16:24:50 -07:00
edi-oai acaf3ca7ef ControlSocket: duplicate VirtIO socket connection's file descriptor (#1333) 2026-09-16 11:37:56 +01:00
Sam EstepandClaude Opus 5 9bb2af2434 Run ErrorReportingTask operations on the caller's actor (#1324)
VZVirtualMachine asserts that it is used on the queue it was created with,
which for tart is the main queue. Since #1262 replaced the unstructured Task
in "tart run"'s SIGUSR2 handler with ErrorReportingTask, that assertion fails:
Task.init carries @_inheritActorContext, but ErrorReportingTask.init did not,
so an operation written inside MainActor-isolated Run.runOnMainThread() is formed
in a nonisolated init and runs on the cooperative pool, not the main queue.

The result is that asking a VM to stop gracefully kills it instead. Sending
SIGUSR2, which #842 hooked to requestStop() for exactly this purpose, crashes
the process:

    Thread 1  queue: com.apple.root.default-qos.cooperative
      _dispatch_assert_queue_fail
      dispatch_assert_queue
      -[VZVirtualMachine requestStopWithError:]
      closure in Run.runOnMainThread()
      closure in ErrorReportingTask.init(_:operation:)

The guest then loses power without a chance to flush, and on a Linux guest
with ext4's default delayed allocation that discards whatever had not been
written back yet. The same applies to the requestStop() in
applicationShouldTerminate(), i.e. closing the window of a VM run with a GUI.

Give the operation the same @_inheritActorContext that Task.init has, so that
wrapping a call in ErrorReportingTask no longer changes where it runs.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 15:05:42 -07:00
Yibo Zhuang cdb3579c79 Fix control socket paths with special characters (#1328) 2026-09-07 10:40:58 -07:00
Yibo Zhuang 3f15df9e3c Preserve stdout when running Softnet without a control FD (#1329) 2026-09-07 09:37:51 -07:00
Yibo Zhuang 1b4813f210 Preserve running VM directories during replacement (#1321) 2026-09-01 18:20:18 -07:00
Yibo Zhuang c4cf73a819 Pin CI and build actions to full commit SHAs (#1325) 2026-09-02 00:41:44 +00:00
40 changed files with 1327 additions and 111 deletions
+3
View File
@@ -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 \
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: xcode-27
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: Build
run: |
export COMPILATION_CACHE_ENABLE_CACHING=YES
@@ -32,6 +32,6 @@ jobs:
runs-on: xcode-27
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
- name: Build
run: swift build --build-system swiftbuild --product tart
+2 -2
View File
@@ -17,8 +17,8 @@ jobs:
runs-on: xcode-27
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: integration-tests/go.mod
cache-dependency-path: integration-tests/go.sum
+14
View File
@@ -34,6 +34,9 @@ struct Clone: AsyncParsableCommand {
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
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"))
var pruneLimit: UInt = 100
@@ -52,6 +55,8 @@ struct Clone: AsyncParsableCommand {
let localStorage = try VMStorageLocal()
let remoteName = try? RemoteName(sourceName)
try rejectExistingDestination(localStorage)
if stacked {
guard remoteName != nil else {
throw ValidationError("--stacked requires a remote image")
@@ -98,6 +103,8 @@ struct Clone: AsyncParsableCommand {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
try rejectExistingDestination(localStorage)
let sourceState = try sourceVM.state()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceState != .Suspended
@@ -151,4 +158,11 @@ struct Clone: AsyncParsableCommand {
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")
}
}
}
+4
View File
@@ -74,6 +74,10 @@ struct Create: AsyncParsableCommand {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
}
// Publish under the same lock that run holds while opening VM files.
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
try storageLock.lock()
defer { withExtendedLifetime(storageLock) {} }
try VMStorageLocal().move(name, from: tmpVMDir)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
+1 -1
View File
@@ -45,7 +45,7 @@ struct Exec: AsyncParsableCommand {
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = vmDir.controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
FileManager.default.changeCurrentDirectoryPath(baseURL.absoluteURL.path(percentEncoded: false))
}
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
+1 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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)
+5 -2
View File
@@ -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
}
@@ -73,7 +76,7 @@ struct IP: AsyncParsableCommand {
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
FileManager.default.changeCurrentDirectoryPath(baseURL.absoluteURL.path(percentEncoded: false))
}
if let ip = try await AgentResolver.ResolveIP(controlSocketURL.relativePath) {
+3 -2
View File
@@ -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(),
+3
View File
@@ -18,6 +18,9 @@ struct Rename: AsyncParsableCommand {
func run() async throws {
let localStorage = try VMStorageLocal()
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
defer { withExtendedLifetime(lock) {} }
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent local VM: \(name)")
+9 -3
View File
@@ -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."))
@@ -425,7 +428,7 @@ struct Run: AsyncParsableCommand {
try vmDir.regenerateMACAddress()
}
if (netSoftnet || netHost) && isInteractiveSession() {
if netSoftnet && isInteractiveSession() {
try Softnet.configureSUIDBitIfNeeded()
}
@@ -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,
@@ -699,9 +703,11 @@ struct Run: AsyncParsableCommand {
}
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 {
+42 -3
View File
@@ -1,4 +1,7 @@
import Foundation
import Darwin
import System
import Virtualization
import Network
import os.log
import NIO
@@ -22,18 +25,24 @@ class ControlSocket {
// Remove control socket file from previous "tart run" invocations,
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
try? FileManager.default.removeItem(at: controlSocketURL)
// Change the current working directory to a VM's base directory
// to work around Unix domain socket 104 byte limitation [1]
//
// [1]: https://blog.8-p.info/en/2020/06/11/unix-domain-socket-length/
if let baseURL = controlSocketURL.baseURL {
FileManager.default.changeCurrentDirectoryPath(baseURL.path())
FileManager.default.changeCurrentDirectoryPath(baseURL.absoluteURL.path(percentEncoded: false))
}
do {
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.serverChannelInitializer { channel in
channel.pipeline.addHandler(
ControlSocketAcceptErrorHandler(),
name: "ControlSocketAcceptErrorHandler"
)
}
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
@@ -72,7 +81,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 +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)
}
}
}
@@ -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,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() {
mac[index] = UInt8(component, radix: 16)!
guard let byte = UInt8(component, radix: 16) else {
return nil
}
mac[index] = byte
}
}
+61
View File
@@ -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))"
}
}
}
+2 -3
View File
@@ -11,14 +11,13 @@ enum SoftnetError: Error {
class Softnet: Network {
private let process = Process()
private var controlFileHandle: FileHandle?
private var monitorTask: Task<Void, Error>? = nil
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
let vmFD: Int32
init(vmMACAddress: String, extraArguments: [String] = [], controlFD: Int32? = nil) throws {
var controlFileHandle: FileHandle?
if let controlFD = controlFD {
guard controlFD > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
@@ -95,7 +94,7 @@ class Softnet: Network {
}
func run(_ sema: AsyncSemaphore) throws {
defer { try? (process.standardOutput as? FileHandle)?.close() }
defer { try? controlFileHandle?.close() }
try process.run()
+38 -19
View File
@@ -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 {
+14 -12
View File
@@ -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)
}
}
}
+6 -6
View File
@@ -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 -5
View File
@@ -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 -8
View File
@@ -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))
}
}
+4 -1
View File
@@ -6,8 +6,11 @@ import Foundation
struct ErrorReportingTask {
let task: Task<Void, Never>
// Inherit the caller's actor context, exactly as Task.init does. Without this, an
// operation written inside a @MainActor function runs on the cooperative pool
// rather than the main queue, trapping in callees that assert their queue.
@discardableResult
init(_ context: String, operation: @escaping @Sendable () async throws -> Void) {
init(_ context: String, @_inheritActorContext operation: @escaping @Sendable () async throws -> Void) {
task = Task {
do {
try await operation()
+37 -19
View File
@@ -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()
+1 -1
View File
@@ -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)
}
-4
View File
@@ -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)
}
+2 -1
View File
@@ -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)
}
}
+9
View File
@@ -47,6 +47,15 @@ class VMStorageLocal: PrunableStorage {
/// References in a manifest must not disappear while content GC is deciding
/// whether their immutable disk files are still in use.
private func replace(_ destination: VMDirectory, with source: VMDirectory) throws {
// Replacing a running VM's directory unlinks its locked config and disks,
// leaving a live VM that list and stop can no longer find by name.
let destinationLock = FileManager.default.fileExists(atPath: destination.configURL.path)
? try destination.lock() : nil
if let destinationLock, try !destinationLock.trylock() {
throw RuntimeError.VMIsRunning(destination.name)
}
defer { withExtendedLifetime(destinationLock) {} }
if FileManager.default.fileExists(atPath: source.manifestURL.path) ||
FileManager.default.fileExists(atPath: destination.manifestURL.path) {
let contentStore = try ContentStore()
+71
View File
@@ -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")!))
}
}
+201
View File
@@ -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,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 {
try await withTemporaryTartHome {
let vmDir = try VMStorageLocal().create("stacked")
@@ -107,6 +262,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] {
try FileManager.default.contentsOfDirectory(
at: Config().tartTmpDir,
@@ -114,6 +277,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"]
+141
View File
@@ -1,6 +1,11 @@
import NIO
import XCTest
@testable import NIOPosix
@testable import tart
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
private let bindTestSocket = bind
@available(macOS 14, *)
final class ControlSocketTests: XCTestCase {
func testInitializerCreatesControlSocketBeforeReturning() async throws {
@@ -26,6 +31,88 @@ final class ControlSocketTests: XCTestCase {
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 {
let temporaryDirectory = try makeTemporaryDirectory()
let originalDirectory = FileManager.default.currentDirectoryPath
@@ -44,6 +131,43 @@ final class ControlSocketTests: XCTestCase {
}
}
func testInitializerReplacesStaleSocketInLongEncodedPath() async throws {
let temporaryDirectory = try makeTemporaryDirectory()
let originalDirectory = FileManager.default.currentDirectoryPath
defer {
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
try? FileManager.default.removeItem(at: temporaryDirectory)
}
let vmDirectory = temporaryDirectory.appendingPathComponent(
"Tart Home %# 虚拟机 " + String(repeating: "v", count: 104), isDirectory: true
)
try FileManager.default.createDirectory(at: vmDirectory, withIntermediateDirectories: false)
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: vmDirectory)
XCTAssertGreaterThan(socketURL.path.utf8.count, 104)
// Closing a POSIX socket leaves its path behind, as exiting "tart run" does.
XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(vmDirectory.path))
let expectedDirectory = FileManager.default.currentDirectoryPath
let address = try SocketAddress(unixDomainSocketPath: "control.sock")
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
XCTAssertGreaterThanOrEqual(descriptor, 0)
XCTAssertEqual(address.withSockAddr { bindTestSocket(descriptor, $0, socklen_t($1)) }, 0)
XCTAssertEqual(close(descriptor), 0)
XCTAssertTrue(FileManager.default.changeCurrentDirectoryPath(originalDirectory))
var controlSocket: ControlSocket? = try await ControlSocket(socketURL)
let eventLoopGroup = try XCTUnwrap(controlSocket?.eventLoopGroup)
do {
let serverChannel = try XCTUnwrap(controlSocket?.serverChannel)
XCTAssertEqual(FileManager.default.currentDirectoryPath, expectedDirectory)
XCTAssertTrue(FileManager.default.fileExists(atPath: socketURL.path))
try await serverChannel.executeThenClose { _ in }
}
controlSocket = nil
try await eventLoopGroup.shutdownGracefully()
}
private func makeTemporaryDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
@@ -53,3 +177,20 @@ final class ControlSocketTests: XCTestCase {
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)
}
}
@@ -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
+88
View File
@@ -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 {
}
}
+14 -6
View File
@@ -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
View File
@@ -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:
+215
View File
@@ -0,0 +1,215 @@
package integration_test
import (
"bytes"
"context"
"encoding/json"
"integration/tart"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"syscall"
"testing"
"time"
)
// Exercise the real CLI with synthetic VM files and the same POSIX record lock
// used by tart run. This needs macOS, but does not boot a VM or download images.
func TestClonePreservesRunningDestination(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")
destination := createSyntheticVM(t, home, "destination", "92:81:b5:ab:39:38")
if _, stderr, err := tart.Tart(t, "clone", "source", "new-destination"); err != nil {
t.Fatalf("clone to new destination: %v: %s", err, stderr)
}
config := filepath.Join(destination, "config.json")
original, err := os.ReadFile(config)
if err != nil {
t.Fatal(err)
}
held, err := os.OpenFile(config, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { held.Close() })
originalInfo, err := held.Stat()
if err != nil {
t.Fatal(err)
}
lock := syscall.Flock_t{Type: syscall.F_WRLCK, Whence: 0}
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
t.Fatal(err)
}
for range 2 {
_, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination")
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
}
currentInfo, err := os.Stat(config)
if err != nil || !os.SameFile(originalInfo, currentInfo) {
t.Fatalf("clone replaced the locked config: %v", err)
}
// Opening and closing config again would release our process's record lock.
current := make([]byte, len(original))
if _, err := held.ReadAt(current, 0); err != nil || !bytes.Equal(original, current) {
t.Fatalf("clone changed the locked config: %v", err)
}
}
lock.Type = syscall.F_UNLCK
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
t.Fatal(err)
}
if _, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination"); err != nil {
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
}
currentInfo, err := os.Stat(config)
if err != nil || os.SameFile(originalInfo, currentInfo) {
t.Fatalf("clone did not replace the stopped destination: %v", err)
}
}
// Run holds the storage lock while opening VM files. Pause publication at the
// prune lock so we can check storage-lock ownership without timing assumptions.
func TestPublishWaitsForVMLookup(t *testing.T) {
for _, operation := range []string{"create", "rename"} {
t.Run(operation, func(t *testing.T) {
if operation == "create" {
// Create validates a VM configuration even without booting it.
// Hosted macOS guests may not expose hardware virtualization.
supported, err := syscall.SysctlUint32("kern.hv_support")
if err != nil {
t.Fatalf("check hypervisor support: %v", err)
}
if supported == 0 {
t.Skip("create requires hardware virtualization; kern.hv_support is 0")
}
}
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")
destination := createSyntheticVM(t, home, "destination", "92:81:b5:ab:39:38")
// Rename accepts an incomplete destination. Its manifest makes
// replacement acquire the content-pruning lock after the PID lock.
if err := os.Remove(filepath.Join(destination, "disk.img")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(destination, "manifest.json"), []byte("{}"), 0600); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(destination, "config.json")
config, err := os.OpenFile(configPath, os.O_RDWR, 0)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { config.Close() })
originalInfo, err := config.Stat()
if err != nil {
t.Fatal(err)
}
prunePath := filepath.Join(home, "cache", "content", ".gc.lock")
if err := os.MkdirAll(filepath.Dir(prunePath), 0700); err != nil {
t.Fatal(err)
}
prune, err := os.OpenFile(prunePath, os.O_CREATE|os.O_RDWR, 0600)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { prune.Close() })
if err := syscall.Flock(int(prune.Fd()), syscall.LOCK_EX); err != nil {
t.Fatal(err)
}
storage, err := os.Open(home)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
args := []string{"rename", "source", "destination"}
if operation == "create" {
args = []string{"create", "--linux", "--disk-size", "1", "destination"}
}
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "tart", args...)
var output bytes.Buffer
cmd.Stdout, cmd.Stderr = &output, &output
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
var waitErr error
go func() { waitErr = cmd.Wait(); close(done) }()
defer func() { cancel(); <-done }()
ticker := time.NewTicker(10 * time.Millisecond)
defer ticker.Stop()
for {
lock := syscall.Flock_t{Type: syscall.F_RDLCK, Whence: 0}
if err := syscall.FcntlFlock(config.Fd(), syscall.F_GETLK, &lock); err != nil {
t.Fatal(err)
}
if lock.Type == syscall.F_WRLCK && lock.Pid == int32(cmd.Process.Pid) {
break
}
select {
case <-done:
t.Fatalf("%s exited before taking the destination PID lock: %v: %s", operation, waitErr, output.String())
case <-ticker.C:
}
}
// The command has reached replacement and cannot finish while
// we hold the prune lock. It must already own the storage lock.
if err := syscall.Flock(int(storage.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
t.Fatalf("%s reached replacement without holding the storage lock", operation)
} else if err != syscall.EWOULDBLOCK {
t.Fatalf("probe storage lock: %v", err)
}
if err := syscall.Flock(int(prune.Fd()), syscall.LOCK_UN); err != nil {
t.Fatal(err)
}
<-done
if waitErr != nil {
t.Fatalf("%s after releasing the prune lock: %v: %s", operation, waitErr, output.String())
}
currentInfo, err := os.Stat(configPath)
if err != nil || os.SameFile(originalInfo, currentInfo) {
t.Fatalf("destination was not replaced: %v", err)
}
})
}
}
func createSyntheticVM(t *testing.T, home, name, mac string) string {
t.Helper()
directory := filepath.Join(home, "vms", name)
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
config, err := json.Marshal(map[string]any{
"version": 1, "os": "linux", "arch": runtime.GOARCH,
"cpuCountMin": 4, "cpuCount": 4,
"memorySizeMin": 4294967296, "memorySize": 4294967296,
"macAddress": mac, "diskFormat": "raw",
"display": map[string]int{"width": 1024, "height": 768},
})
if err != nil {
t.Fatal(err)
}
for filename, contents := range map[string][]byte{
"config.json": config, "disk.img": make([]byte, 4096), "nvram.bin": {},
} {
if err := os.WriteFile(filepath.Join(directory, filename), contents, 0600); err != nil {
t.Fatal(err)
}
}
return directory
}
+60
View File
@@ -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)
}
})
}
}
+17 -4
View File
@@ -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