mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05edeac562 | ||
|
|
917c0b51ed | ||
|
|
e3b4f71f77 | ||
|
|
e92c3b900f | ||
|
|
5c1c6bd315 | ||
|
|
27d3e2c5da | ||
|
|
a80ec74a42 | ||
|
|
8ac52501c3 |
@@ -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 \
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ struct Run: AsyncParsableCommand {
|
||||
try vmDir.regenerateMACAddress()
|
||||
}
|
||||
|
||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
||||
if netSoftnet && isInteractiveSession() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
}
|
||||
|
||||
@@ -703,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 {
|
||||
|
||||
@@ -37,6 +37,12 @@ class ControlSocket {
|
||||
|
||||
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>(
|
||||
@@ -125,3 +131,16 @@ class ControlSocket {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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")!))
|
||||
}
|
||||
}
|
||||
@@ -116,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")
|
||||
@@ -202,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,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import NIO
|
||||
import XCTest
|
||||
@testable import NIOPosix
|
||||
@testable import tart
|
||||
|
||||
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
||||
@@ -30,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
|
||||
@@ -94,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
_, stderr, err := tart.Tart(t, "clone", "source", "destination")
|
||||
_, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination")
|
||||
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
|
||||
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
||||
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, stderr, err := tart.Tart(t, "clone", "source", "destination"); err != nil {
|
||||
if _, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination"); err != nil {
|
||||
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
|
||||
}
|
||||
currentInfo, err := os.Stat(config)
|
||||
|
||||
Reference in New Issue
Block a user