mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-18 03:53:56 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
543453404c | ||
|
|
bb5cb01d31 | ||
|
|
6c31a73166 | ||
|
|
e2042dedcd | ||
|
|
12501370cc | ||
|
|
7d39f4cf68 | ||
|
|
8364f4ccac | ||
|
|
ef925e433d | ||
|
|
37af26a810 | ||
|
|
05edeac562 | ||
|
|
917c0b51ed | ||
|
|
e3b4f71f77 | ||
|
|
e92c3b900f |
@@ -34,6 +34,9 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
|
@Flag(help: "create a stacked disk that uses the source image as an immutable base")
|
||||||
var stacked: Bool = false
|
var stacked: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "overwrite an existing local VM")
|
||||||
|
var overwrite: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
@Option(help: ArgumentHelp("limit automatic pruning to n gigabytes", valueName: "n"))
|
||||||
var pruneLimit: UInt = 100
|
var pruneLimit: UInt = 100
|
||||||
|
|
||||||
@@ -52,6 +55,8 @@ struct Clone: AsyncParsableCommand {
|
|||||||
let localStorage = try VMStorageLocal()
|
let localStorage = try VMStorageLocal()
|
||||||
let remoteName = try? RemoteName(sourceName)
|
let remoteName = try? RemoteName(sourceName)
|
||||||
|
|
||||||
|
try rejectExistingDestination(localStorage)
|
||||||
|
|
||||||
if stacked {
|
if stacked {
|
||||||
guard remoteName != nil else {
|
guard remoteName != nil else {
|
||||||
throw ValidationError("--stacked requires a remote image")
|
throw ValidationError("--stacked requires a remote image")
|
||||||
@@ -98,6 +103,8 @@ struct Clone: AsyncParsableCommand {
|
|||||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
|
|
||||||
|
try rejectExistingDestination(localStorage)
|
||||||
|
|
||||||
let sourceState = try sourceVM.state()
|
let sourceState = try sourceVM.state()
|
||||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||||
&& sourceState != .Suspended
|
&& sourceState != .Suspended
|
||||||
@@ -151,4 +158,11 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try? tmpVMDir.removeFromDisk()
|
try? tmpVMDir.removeFromDisk()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func rejectExistingDestination(_ localStorage: VMStorageLocal) throws {
|
||||||
|
let destinationURL = localStorage.baseURL.appendingPathComponent(newName, isDirectory: true)
|
||||||
|
if !overwrite && FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||||
|
throw ValidationError("VM \"\(newName)\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
let DiskFormat: String
|
let DiskFormat: String
|
||||||
let Size: HumanReadableByteCount
|
let Size: HumanReadableByteCount
|
||||||
let Display: String
|
let Display: String
|
||||||
|
let MACAddress: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,7 @@ struct Get: AsyncParsableCommand {
|
|||||||
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
|
||||||
},
|
},
|
||||||
Display: vmConfig.display.description,
|
Display: vmConfig.display.description,
|
||||||
|
MACAddress: vmConfig.macAddress.string,
|
||||||
Running: try vmDir.running(),
|
Running: try vmDir.running(),
|
||||||
State: try vmDir.state().rawValue
|
State: try vmDir.state().rawValue
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,31 +38,47 @@ struct List: AsyncParsableCommand {
|
|||||||
var infos: [VMInfo] = []
|
var infos: [VMInfo] = []
|
||||||
|
|
||||||
if source == nil || source == "local" {
|
if source == nil || source == "local" {
|
||||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
infos += sortedInfos(try VMStorageLocal().list().compactMap { (name, vmDir) -> VMInfo? in
|
||||||
try VMInfo(
|
do {
|
||||||
Source: "local",
|
return try VMInfo(
|
||||||
Name: name,
|
Source: "local",
|
||||||
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
Name: name,
|
||||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
// ASIF capacity lookup can fail while a running VM holds the disk open.
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Running: vmDir.running(),
|
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||||
State: vmDir.state().rawValue
|
Running: vmDir.running(),
|
||||||
)
|
State: vmDir.state().rawValue
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// A VM can disappear after storage enumeration when another process deletes it.
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == nil || source == "oci" {
|
if source == nil || source == "oci" {
|
||||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
infos += sortedInfos(try VMStorageOCI().list().compactMap { (name, vmDir, _) -> VMInfo? in
|
||||||
try VMInfo(
|
do {
|
||||||
Source: "OCI",
|
return try VMInfo(
|
||||||
Name: name,
|
Source: "OCI",
|
||||||
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Name: name,
|
||||||
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Accessed: formatAccessDate(try vmDir.accessDate()),
|
Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 },
|
||||||
Running: vmDir.running(),
|
Accessed: formatAccessDate(try vmDir.accessDate()),
|
||||||
State: vmDir.state().rawValue
|
Running: vmDir.running(),
|
||||||
)
|
State: vmDir.state().rawValue
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// A VM can disappear after storage enumeration when another process deletes it.
|
||||||
|
if error.isFileNotFound() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,6 +187,17 @@ struct Run: AsyncParsableCommand {
|
|||||||
""", valueName: "interface name"))
|
""", valueName: "interface name"))
|
||||||
var netBridged: [String] = []
|
var netBridged: [String] = []
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Use a connected datagram Unix domain socket inherited from the parent process for networking (e.g. --net-fd=3)",
|
||||||
|
discussion: """
|
||||||
|
The socket carries raw Ethernet frames, one per datagram.
|
||||||
|
|
||||||
|
The simplest use-case is composing Tart and Softnet invocations that are orchestrated by another program, such as Orchard.
|
||||||
|
|
||||||
|
Another way to use this argument is to connect a VM to any other networking implementation such as gVisor or software-defined networking implementation.
|
||||||
|
""",
|
||||||
|
valueName: "file descriptor"))
|
||||||
|
var netFd: Int32?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
@Flag(help: ArgumentHelp("Use software networking provided by Softnet instead of the default shared (NAT) networking",
|
||||||
discussion: """
|
discussion: """
|
||||||
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
Softnet provides better network isolation and alleviates DHCP shortage on production systems. Tart invokes Softnet when this option is specified as a sub-process and communicates with it over socketpair(2).
|
||||||
@@ -332,9 +343,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
if netBridged.count > 0 { netFlags += 1 }
|
if netBridged.count > 0 { netFlags += 1 }
|
||||||
if netSoftnet { netFlags += 1 }
|
if netSoftnet { netFlags += 1 }
|
||||||
if netHost { netFlags += 1 }
|
if netHost { netFlags += 1 }
|
||||||
|
if netFd != nil { netFlags += 1 }
|
||||||
|
|
||||||
if netFlags > 1 {
|
if netFlags > 1 {
|
||||||
throw ValidationError("--net-bridged, --net-softnet and --net-host are mutually exclusive")
|
throw ValidationError("--net-bridged, --net-softnet, --net-host and --net-fd are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
if graphics && noGraphics {
|
if graphics && noGraphics {
|
||||||
@@ -428,7 +440,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
try vmDir.regenerateMACAddress()
|
try vmDir.regenerateMACAddress()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (netSoftnet || netHost) && isInteractiveSession() {
|
if netSoftnet && isInteractiveSession() {
|
||||||
try Softnet.configureSUIDBitIfNeeded()
|
try Softnet.configureSUIDBitIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,9 +715,11 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if netHost {
|
if netHost {
|
||||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
guard #available(macOS 26, *) else {
|
||||||
|
throw ValidationError("--net-host requires macOS 26 (Tahoe) or newer")
|
||||||
|
}
|
||||||
|
|
||||||
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"] + softnetExtraArguments, controlFD: netSoftnetControlFd)
|
return try NetworkHost()
|
||||||
}
|
}
|
||||||
|
|
||||||
if netBridged.count > 0 {
|
if netBridged.count > 0 {
|
||||||
@@ -723,6 +737,10 @@ struct Run: AsyncParsableCommand {
|
|||||||
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let netFd = netFd {
|
||||||
|
return try NetworkFD(fd: netFd)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
||||||
var displayRefit: Bool? = nil
|
var displayRefit: Bool? = nil
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("MAC address to use."))
|
||||||
|
var mac: String?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||||
var randomMAC: Bool = false
|
var randomMAC: Bool = false
|
||||||
|
|
||||||
@@ -47,6 +50,10 @@ struct Set: AsyncParsableCommand {
|
|||||||
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
throw ValidationError("--disk is not supported for VMs with a stacked disk")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if randomMAC && mac != nil {
|
||||||
|
throw ValidationError("--random-mac and --mac are mutually exclusive")
|
||||||
|
}
|
||||||
|
|
||||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
if let cpu = cpu {
|
if let cpu = cpu {
|
||||||
@@ -67,7 +74,17 @@ struct Set: AsyncParsableCommand {
|
|||||||
vmConfig.display.unit = display.unit
|
vmConfig.display.unit = display.unit
|
||||||
}
|
}
|
||||||
|
|
||||||
vmConfig.displayRefit = displayRefit
|
if let displayRefit = displayRefit {
|
||||||
|
vmConfig.displayRefit = displayRefit
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mac = mac {
|
||||||
|
if let macAddress = VZMACAddress.init(string: mac), macAddress.isUnicastAddress {
|
||||||
|
vmConfig.macAddress = macAddress
|
||||||
|
} else {
|
||||||
|
throw ValidationError("'\(mac)' is not a valid unicast MAC address")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if randomMAC {
|
if randomMAC {
|
||||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class ControlSocket {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
self.serverChannel = try await ServerBootstrap(group: eventLoopGroup)
|
||||||
|
.serverChannelInitializer { channel in
|
||||||
|
channel.pipeline.addHandler(
|
||||||
|
ControlSocketAcceptErrorHandler(),
|
||||||
|
name: "ControlSocketAcceptErrorHandler"
|
||||||
|
)
|
||||||
|
}
|
||||||
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
.bind(unixDomainSocketPath: controlSocketURL.relativePath) { childChannel in
|
||||||
childChannel.eventLoop.makeCompletedFuture {
|
childChannel.eventLoop.makeCompletedFuture {
|
||||||
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
|
||||||
@@ -125,3 +131,16 @@ class ControlSocket {
|
|||||||
return fd
|
return fd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ControlSocketAcceptErrorHandler: ChannelInboundHandler {
|
||||||
|
typealias InboundIn = Channel
|
||||||
|
typealias InboundOut = Channel
|
||||||
|
|
||||||
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||||
|
if error is NIOFcntlFailedError {
|
||||||
|
context.channel.read()
|
||||||
|
} else {
|
||||||
|
context.fireErrorCaught(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ struct DockerAuthConfig: Codable {
|
|||||||
guard let data = Data(base64Encoded: authBase64) else {
|
guard let data = Data(base64Encoded: authBase64) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
guard let components = String(data: data, encoding: .utf8)?.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if components.count != 2 {
|
if components.count != 2 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return (components[0], components[1])
|
return (String(components[0]), String(components[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ func withGuestAgentChannel<T>(
|
|||||||
let channel = try GRPCChannelPool.with(
|
let channel = try GRPCChannelPool.with(
|
||||||
target: .unixDomainSocket(socketPath),
|
target: .unixDomainSocket(socketPath),
|
||||||
transportSecurity: .plaintext,
|
transportSecurity: .plaintext,
|
||||||
eventLoopGroup: .singletonMultiThreadedEventLoopGroup,
|
eventLoopGroup: .singletonMultiThreadedEventLoopGroup
|
||||||
)
|
) { configuration in
|
||||||
|
// Limit guest output in flight to work around VirtIO socket device implementation data loss
|
||||||
|
configuration.http2.targetWindowSize = 64 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await body(channel)
|
let result = try await body(channel)
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible {
|
|||||||
struct ARPCache {
|
struct ARPCache {
|
||||||
let arpCommandOutput: Data
|
let arpCommandOutput: Data
|
||||||
|
|
||||||
init() throws {
|
init(arpCommandURL: URL = URL(fileURLWithPath: "/usr/sbin/arp")) throws {
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
process.executableURL = arpCommandURL
|
||||||
process.arguments = ["-an"]
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
@@ -53,9 +53,7 @@ struct ARPCache {
|
|||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() ?? Data()
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
|
||||||
}
|
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
@@ -69,9 +67,12 @@ struct ARPCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||||
let lines = String(decoding: arpCommandOutput, as: UTF8.self)
|
let output = String(decoding: arpCommandOutput, as: UTF8.self)
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
.components(separatedBy: "\n")
|
if output.isEmpty {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let lines = output.components(separatedBy: "\n")
|
||||||
|
|
||||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ struct Bootptab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for line in contents.split(whereSeparator: \.isNewline) {
|
for line in contents.split(whereSeparator: \.isNewline) {
|
||||||
|
// Skip comment lines
|
||||||
|
guard !line.hasPrefix("#") else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let fields = line.split(whereSeparator: \.isWhitespace)
|
let fields = line.split(whereSeparator: \.isWhitespace)
|
||||||
|
|
||||||
// Skip lines that don't look like reservation fields
|
// Skip lines that don't look like reservation fields
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkFD: Network {
|
||||||
|
private let attachment: VZFileHandleNetworkDeviceAttachment
|
||||||
|
|
||||||
|
init(fd: Int32) throws {
|
||||||
|
do {
|
||||||
|
try Softnet.validateControlFD(fd, type: SOCK_DGRAM)
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("--net-fd \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = fcntl(fd, F_GETFD)
|
||||||
|
guard flags != -1 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) != -1 else {
|
||||||
|
throw RuntimeError.Generic("failed to set close-on-exec on --net-fd: \(Errno(rawValue: errno))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fileHandle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[attachment]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
|
import Virtualization
|
||||||
|
import vmnet
|
||||||
|
|
||||||
|
@available(macOS 26, *)
|
||||||
|
class NetworkHost: Network {
|
||||||
|
private let attachment: VZNetworkDeviceAttachment
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
var status = vmnet_return_t.VMNET_SUCCESS
|
||||||
|
guard let configuration = vmnet_network_configuration_create(.VMNET_HOST_MODE, &status) else {
|
||||||
|
throw RuntimeError.Generic("Failed to create a vmnet configuration for host-only networking: \(status)")
|
||||||
|
}
|
||||||
|
defer { Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(configuration)).release() }
|
||||||
|
|
||||||
|
guard let network = vmnet_network_create(configuration, &status) else {
|
||||||
|
var message = "Failed to create a vmnet network for host-only networking: \(status)"
|
||||||
|
|
||||||
|
if status == .VMNET_NOT_AUTHORIZED {
|
||||||
|
message += ". Creating a vmnet network requires root privileges or a properly signed app with the com.apple.vm.networking entitlement."
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.Generic(message)
|
||||||
|
}
|
||||||
|
defer { Unmanaged<CFTypeRef>.fromOpaque(UnsafeRawPointer(network)).release() }
|
||||||
|
|
||||||
|
attachment = VZVmnetNetworkDeviceAttachment(network: network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
|
[attachment]
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() async throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension vmnet_return_t: @retroactive CustomStringConvertible {
|
||||||
|
public var description: String {
|
||||||
|
switch self {
|
||||||
|
case .VMNET_SUCCESS: return "successfully completed"
|
||||||
|
case .VMNET_FAILURE: return "general failure"
|
||||||
|
case .VMNET_MEM_FAILURE: return "memory allocation failure"
|
||||||
|
case .VMNET_INVALID_ARGUMENT: return "invalid argument specified"
|
||||||
|
case .VMNET_SETUP_INCOMPLETE: return "interface setup is not complete"
|
||||||
|
case .VMNET_INVALID_ACCESS: return "permission denied"
|
||||||
|
case .VMNET_PACKET_TOO_BIG: return "packet size larger than MTU"
|
||||||
|
case .VMNET_BUFFER_EXHAUSTED: return "buffers exhausted in kernel"
|
||||||
|
case .VMNET_TOO_MANY_PACKETS: return "packet count exceeds limit"
|
||||||
|
case .VMNET_SHARING_SERVICE_BUSY: return "vmnet interface cannot be started as conflicting sharing service is in use"
|
||||||
|
case .VMNET_NOT_AUTHORIZED: return "the operation could not be completed due to missing authorization"
|
||||||
|
@unknown default: return "unknown vmnet status (\(rawValue))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
|
|
||||||
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
controlFileHandle = FileHandle(fileDescriptor: controlFD, closeOnDealloc: true)
|
||||||
try Self.validateControlFD(controlFD)
|
do {
|
||||||
|
try Self.validateControlFD(controlFD)
|
||||||
|
} catch {
|
||||||
|
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
@@ -50,20 +54,16 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func validateControlFD(_ fd: Int32) throws {
|
static func validateControlFD(_ fd: Int32, type: Int32 = SOCK_STREAM) throws {
|
||||||
guard fd > STDERR_FILENO else {
|
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
|
|
||||||
}
|
|
||||||
|
|
||||||
var socketType: Int32 = 0
|
var socketType: Int32 = 0
|
||||||
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
|
||||||
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not a socket: \(details)")
|
throw RuntimeError.Generic("is not a socket: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard socketType == SOCK_STREAM else {
|
guard socketType == type else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a \(type == SOCK_STREAM ? "stream" : "datagram") socket")
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerAddress = sockaddr_storage()
|
var peerAddress = sockaddr_storage()
|
||||||
@@ -75,11 +75,11 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
guard result == 0 else {
|
guard result == 0 else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor is not connected: \(details)")
|
throw RuntimeError.Generic("is not connected: \(details)")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
guard peerAddress.ss_family == sa_family_t(AF_UNIX) else {
|
||||||
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
|
throw RuntimeError.Generic("must be a Unix socket")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,11 +226,26 @@ struct OCIConfig: Codable {
|
|||||||
var architecture: Architecture = .arm64
|
var architecture: Architecture = .arm64
|
||||||
var os: OS = .darwin
|
var os: OS = .darwin
|
||||||
var config: ConfigContainer?
|
var config: ConfigContainer?
|
||||||
|
// Required by the OCI image spec and enforced by registries that validate
|
||||||
|
// image configs (e.g. Quay). Optional so that configs pushed by older Tart
|
||||||
|
// versions, which lack it, can still be decoded.
|
||||||
|
var rootfs: RootFS? = RootFS()
|
||||||
|
|
||||||
struct ConfigContainer: Codable {
|
struct ConfigContainer: Codable {
|
||||||
var Labels: [String: String]?
|
var Labels: [String: String]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct RootFS: Codable {
|
||||||
|
var type: String = "layers"
|
||||||
|
// Tart layers are not filesystem tarballs, so there are no DiffIDs to list
|
||||||
|
var diffIDs: [String] = []
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case type
|
||||||
|
case diffIDs = "diff_ids"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func toJSON() throws -> Data {
|
func toJSON() throws -> Data {
|
||||||
try Config.jsonEncoder().encode(self)
|
try Config.jsonEncoder().encode(self)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
let dateFormatter = ISO8601DateFormatter()
|
let dateFormatter = ISO8601DateFormatter()
|
||||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
let fractionalDateFormatter = ISO8601DateFormatter()
|
||||||
|
fractionalDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
fractionalDateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
|
||||||
decoder.dateDecodingStrategy = .custom { decoder in
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
let dateString = try container.decode(String.self)
|
let dateString = try container.decode(String.self)
|
||||||
|
|
||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? fractionalDateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import XCTest
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class ARPCacheTests: XCTestCase {
|
||||||
|
private let mac = MACAddress(fromString: "00:11:22:33:44:55")!
|
||||||
|
|
||||||
|
private func cache(_ output: String, exitCode: Int = 0) throws -> ARPCache {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let command = directory.appendingPathComponent("arp-fixture")
|
||||||
|
let quoted = output.replacingOccurrences(of: "'", with: "'\"'\"'")
|
||||||
|
try "#!/bin/sh\nprintf '%s' '\(quoted)'\nexit \(exitCode)\n".write(to: command, atomically: true, encoding: .utf8)
|
||||||
|
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: command.path)
|
||||||
|
return try ARPCache(arpCommandURL: command)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptySuccessfulOutputHasNoAddress() throws {
|
||||||
|
XCTAssertNil(try cache("").ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWhitespaceSuccessfulOutputHasNoAddress() throws {
|
||||||
|
for output in ["\n", " \t\r\n", "\n\n"] {
|
||||||
|
XCTAssertNil(try cache(output).ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyOutputPreservesCommandFailure() throws {
|
||||||
|
XCTAssertThrowsError(try cache("", exitCode: 7)) { error in
|
||||||
|
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonemptyOutputPreservesCommandFailure() throws {
|
||||||
|
XCTAssertThrowsError(try cache("synthetic failure", exitCode: 9)) { error in
|
||||||
|
XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testValidEntryResolvesAddress() throws {
|
||||||
|
let value = try cache("? (192.0.2.8) at 0:11:22:33:44:55 on en0 ifscope [ethernet]\n")
|
||||||
|
XCTAssertEqual(try value.ResolveMACAddress(macAddress: mac), IPv4Address("192.0.2.8"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIncompleteAndOtherEntriesDoNotMatch() throws {
|
||||||
|
let value = try cache("""
|
||||||
|
? (192.0.2.9) at (incomplete) on en0 ifscope [ethernet]
|
||||||
|
? (192.0.2.10) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
|
||||||
|
""")
|
||||||
|
XCTAssertNil(try value.ResolveMACAddress(macAddress: mac))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMalformedNonemptyOutputStillFails() throws {
|
||||||
|
XCTAssertThrowsError(try cache("synthetic invalid entry").ResolveMACAddress(macAddress: mac)) {
|
||||||
|
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidAddressesStillFail() throws {
|
||||||
|
for line in [
|
||||||
|
"? (not-an-ip) at 0:11:22:33:44:55 on en0 ifscope [ethernet]",
|
||||||
|
"? (192.0.2.8) at not-a-mac on en0 ifscope [ethernet]",
|
||||||
|
] {
|
||||||
|
XCTAssertThrowsError(try cache(line).ResolveMACAddress(macAddress: mac)) {
|
||||||
|
XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,37 @@ import Network
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class BootptabTests: XCTestCase {
|
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 {
|
func testResolveMACAddress() throws {
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,45 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListSkipsLocalVMsDeletedDuringMetadataLookup() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: ["--source", "local"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSkipsOCIVMsDeletedDuringMetadataLookup() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: ["--source", "oci"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListSkipsVMsDeletedDuringMetadataLookupAcrossSources() async throws {
|
||||||
|
try await assertListSurvivesDeletion(sourceArguments: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListPreservesMetadataPermissionErrors() async throws {
|
||||||
|
for source in ["local", "oci"] {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let vmDir = try source == "local" ? VMStorageLocal().create("inaccessible") :
|
||||||
|
VMStorageOCI().create(RemoteName("example.com/org/inaccessible:latest"))
|
||||||
|
var vmConfig = config()
|
||||||
|
vmConfig.diskFormat = .asif
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: vmDir.baseURL.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/chmod 000 \"${4%/*}\"")
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
do {
|
||||||
|
_ = try await commandOutput(List.self, ["--source", source, "--format", "json"])
|
||||||
|
XCTFail("list should report metadata permission errors")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual((error as NSError).domain, NSCocoaErrorDomain)
|
||||||
|
XCTAssertEqual((error as NSError).code, NSFileReadNoPermissionError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testGetSurvivesUnavailableDiskCapacity() async throws {
|
func testGetSurvivesUnavailableDiskCapacity() async throws {
|
||||||
try await withTemporaryTartHome {
|
try await withTemporaryTartHome {
|
||||||
let previousPath = try installUnavailableDiskutil()
|
let previousPath = try installUnavailableDiskutil()
|
||||||
@@ -122,6 +161,60 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
XCTAssertFalse(RuntimeError.VMIsRunning("running").isFileNotFound())
|
XCTAssertFalse(RuntimeError.VMIsRunning("running").isFileNotFound())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCloneRejectsExistingDestinationUnlessOverwriteIsRequested() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let source = try makeStandaloneVM(named: "source", diskContents: "source")
|
||||||
|
let destination = try makeStandaloneVM(named: "destination", diskContents: "existing")
|
||||||
|
|
||||||
|
let command = try Clone.parseAsRoot(["source", "destination"]) as! Clone
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await command.run()
|
||||||
|
XCTFail("expected cloning over an existing VM to be rejected")
|
||||||
|
} catch let error as ValidationError {
|
||||||
|
XCTAssertEqual(error.message, "VM \"destination\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: destination.diskURL), Data("existing".utf8))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: source.diskURL.path))
|
||||||
|
|
||||||
|
let overwriteCommand = try Clone.parseAsRoot(["--overwrite", "source", "destination"]) as! Clone
|
||||||
|
try await overwriteCommand.run()
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: destination.diskURL), Data("source".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClonePreservesIncompleteDestination() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
_ = try makeStandaloneVM(named: "source", diskContents: "source")
|
||||||
|
let storage = try VMStorageLocal()
|
||||||
|
let destination = storage.baseURL.appendingPathComponent("destination", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||||
|
let diskURL = destination.appendingPathComponent("disk.img")
|
||||||
|
try Data("existing".utf8).write(to: diskURL)
|
||||||
|
XCTAssertFalse(storage.exists("destination"))
|
||||||
|
|
||||||
|
// Check both a local source and rejection before any remote registry access.
|
||||||
|
for source in ["source", "invalid.invalid/image:latest"] {
|
||||||
|
let command = try Clone.parseAsRoot([source, "destination"]) as! Clone
|
||||||
|
do {
|
||||||
|
try await command.run()
|
||||||
|
XCTFail("expected an incomplete destination to be preserved")
|
||||||
|
} catch let error as ValidationError {
|
||||||
|
XCTAssertEqual(error.message, "VM \"destination\" already exists, use --overwrite to replace it")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(try Data(contentsOf: diskURL), Data("existing".utf8))
|
||||||
|
XCTAssertFalse(storage.exists("destination"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let command = try Clone.parseAsRoot(["--overwrite", "source", "destination"]) as! Clone
|
||||||
|
try await command.run()
|
||||||
|
XCTAssertEqual(try Data(contentsOf: diskURL), Data("source".utf8))
|
||||||
|
XCTAssertTrue(storage.exists("destination"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
func testSetDiskRejectsStackedVMBeforeSavingConfig() async throws {
|
||||||
try await withTemporaryTartHome {
|
try await withTemporaryTartHome {
|
||||||
let vmDir = try VMStorageLocal().create("stacked")
|
let vmDir = try VMStorageLocal().create("stacked")
|
||||||
@@ -152,6 +245,22 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testSetPreservesDisplayRefitWhenChangingOtherOptions() async throws {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
let vmDir = try makeStandaloneVM(named: "display-refit", diskContents: "disk")
|
||||||
|
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
vmConfig.displayRefit = false
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
let command = try Set.parseAsRoot(["display-refit", "--cpu", "4"]) as! Set
|
||||||
|
try await command.run()
|
||||||
|
|
||||||
|
let updatedConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
XCTAssertEqual(updatedConfig.displayRefit, false)
|
||||||
|
XCTAssertEqual(updatedConfig.cpuCount, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
func testRemoteAdditionalDiskRetainsTemporaryBackingFileLock() throws {
|
||||||
try withTemporaryTartHome {
|
try withTemporaryTartHome {
|
||||||
let storage = try VMStorageOCI()
|
let storage = try VMStorageOCI()
|
||||||
@@ -199,6 +308,46 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func assertListSurvivesDeletion(sourceArguments: [String]) async throws {
|
||||||
|
let digest = "sha256:" + String(repeating: "a", count: 64)
|
||||||
|
for outputArguments in [["--format", "json"], [], ["--quiet"]] {
|
||||||
|
try await withTemporaryTartHome {
|
||||||
|
// diskutil runs after enumeration, making the deletion race deterministic.
|
||||||
|
let previousPath = try installUnavailableDiskutil(beforeFailure: "/bin/rm -rf \"${4%/*}\"")
|
||||||
|
defer { restoreEnvironment("PATH", to: previousPath) }
|
||||||
|
|
||||||
|
let local = try VMStorageLocal()
|
||||||
|
let oci = try VMStorageOCI()
|
||||||
|
for (name, diskFormat) in [("disappearing", DiskImageFormat.asif), ("healthy", .raw)] {
|
||||||
|
let remoteName = try RemoteName("example.com/org/\(name)@\(digest)")
|
||||||
|
for vmDir in [try local.create(name), try oci.create(remoteName)] {
|
||||||
|
var vmConfig = config()
|
||||||
|
vmConfig.diskFormat = diskFormat
|
||||||
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let arguments = sourceArguments + outputArguments
|
||||||
|
let output = try await commandOutput(List.self, arguments)
|
||||||
|
let expectedNames = sourceArguments.isEmpty ? ["healthy", "example.com/org/healthy@\(digest)"] :
|
||||||
|
[sourceArguments.last == "local" ? "healthy" : "example.com/org/healthy@\(digest)"]
|
||||||
|
if outputArguments == ["--format", "json"] {
|
||||||
|
let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(output.utf8)) as? [[String: Any]])
|
||||||
|
XCTAssertEqual(rows.compactMap { $0["Name"] as? String }, expectedNames)
|
||||||
|
} else if outputArguments == ["--quiet"] {
|
||||||
|
XCTAssertEqual(output.split(separator: "\n").map(String.init), expectedNames)
|
||||||
|
} else {
|
||||||
|
for name in expectedNames {
|
||||||
|
XCTAssertTrue(output.contains(name))
|
||||||
|
}
|
||||||
|
XCTAssertFalse(output.contains("disappearing"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func config() -> VMConfig {
|
private func config() -> VMConfig {
|
||||||
VMConfig(
|
VMConfig(
|
||||||
platform: Linux(),
|
platform: Linux(),
|
||||||
@@ -208,6 +357,14 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeStandaloneVM(named name: String, diskContents: String) throws -> VMDirectory {
|
||||||
|
let vmDir = try VMStorageLocal().create(name)
|
||||||
|
try config().save(toURL: vmDir.configURL)
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data()))
|
||||||
|
XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.diskURL.path, contents: Data(diskContents.utf8)))
|
||||||
|
return vmDir
|
||||||
|
}
|
||||||
|
|
||||||
private func temporaryEntries() throws -> [URL] {
|
private func temporaryEntries() throws -> [URL] {
|
||||||
try FileManager.default.contentsOfDirectory(
|
try FileManager.default.contentsOfDirectory(
|
||||||
at: Config().tartTmpDir,
|
at: Config().tartTmpDir,
|
||||||
@@ -215,11 +372,12 @@ final class CommandBehaviorTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func installUnavailableDiskutil() throws -> String? {
|
private func installUnavailableDiskutil(beforeFailure: String = "") throws -> String? {
|
||||||
let binDirectory = try temporaryDirectory()
|
let binDirectory = try temporaryDirectory()
|
||||||
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
let diskutilURL = binDirectory.appendingPathComponent("diskutil")
|
||||||
let script = """
|
let script = """
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
\(beforeFailure)
|
||||||
echo 'Resource temporarily unavailable' >&2
|
echo 'Resource temporarily unavailable' >&2
|
||||||
exit 1
|
exit 1
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import NIO
|
import NIO
|
||||||
import XCTest
|
import XCTest
|
||||||
|
@testable import NIOPosix
|
||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
// Avoid NSObject.bind and Tart's Darwin type shadowing the system function.
|
||||||
@@ -30,6 +31,88 @@ final class ControlSocketTests: XCTestCase {
|
|||||||
try await eventLoopGroup.shutdownGracefully()
|
try await eventLoopGroup.shutdownGracefully()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAcceptErrorsRetryReadingAndForwardOtherErrors() async throws {
|
||||||
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
|
defer {
|
||||||
|
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||||
|
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
|
||||||
|
let controlSocket = try await ControlSocket(socketURL)
|
||||||
|
let channel = controlSocket.serverChannel.channel
|
||||||
|
let observations = try await channel.eventLoop.submit {
|
||||||
|
let observer = AcceptErrorObserver()
|
||||||
|
// Placing this after the recovery handler also detects context.read(), which
|
||||||
|
// bypasses downstream backpressure handlers instead of starting at the tail.
|
||||||
|
try channel.pipeline.syncOperations.addHandler(observer)
|
||||||
|
for _ in 0..<3 {
|
||||||
|
channel.pipeline.fireErrorCaught(NIOFcntlFailedError())
|
||||||
|
}
|
||||||
|
let retries = observer.readCount
|
||||||
|
let transientErrors = observer.errors.count
|
||||||
|
channel.pipeline.fireErrorCaught(ChannelError.inputClosed)
|
||||||
|
return (retries, transientErrors, observer.readCount,
|
||||||
|
observer.errors.count, observer.errors.first as? ChannelError)
|
||||||
|
}.get()
|
||||||
|
|
||||||
|
try await controlSocket.serverChannel.executeThenClose { _ in }
|
||||||
|
try await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
|
||||||
|
XCTAssertEqual(observations.0, 3)
|
||||||
|
XCTAssertEqual(observations.1, 0)
|
||||||
|
XCTAssertEqual(observations.2, 3)
|
||||||
|
XCTAssertEqual(observations.3, 1)
|
||||||
|
XCTAssertEqual(observations.4, .inputClosed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAcceptErrorsDoNotEndInboundConnections() async throws {
|
||||||
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
|
defer {
|
||||||
|
FileManager.default.changeCurrentDirectoryPath(originalDirectory)
|
||||||
|
try? FileManager.default.removeItem(at: temporaryDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
let socketURL = URL(fileURLWithPath: "control.sock", relativeTo: temporaryDirectory)
|
||||||
|
let controlSocket = try await ControlSocket(socketURL)
|
||||||
|
let serverChannel = controlSocket.serverChannel
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await serverChannel.executeThenClose { inbound in
|
||||||
|
// Bound the wait if the listener stays open but stops accepting connections.
|
||||||
|
let timeout = serverChannel.channel.eventLoop.scheduleTask(in: .seconds(10)) {
|
||||||
|
serverChannel.channel.close(promise: nil)
|
||||||
|
}
|
||||||
|
defer { timeout.cancel() }
|
||||||
|
|
||||||
|
var iterator = inbound.makeAsyncIterator()
|
||||||
|
for _ in 0..<3 {
|
||||||
|
try await serverChannel.channel.eventLoop.submit {
|
||||||
|
serverChannel.channel.pipeline.fireErrorCaught(NIOFcntlFailedError())
|
||||||
|
}.get()
|
||||||
|
|
||||||
|
let clientChannel = try await ClientBootstrap(group: controlSocket.eventLoopGroup)
|
||||||
|
.connectTimeout(.seconds(5))
|
||||||
|
.connect(unixDomainSocketPath: socketURL.path)
|
||||||
|
.get()
|
||||||
|
defer { clientChannel.close(promise: nil) }
|
||||||
|
|
||||||
|
// ControlSocket.run() consumes this stream. A recoverable accept error
|
||||||
|
// must not prevent it from receiving the next connection.
|
||||||
|
let nextChannel = try await iterator.next()
|
||||||
|
let acceptedChannel = try XCTUnwrap(nextChannel, "The listener stopped delivering connections after an accept error")
|
||||||
|
try await acceptedChannel.executeThenClose { _, _ in }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
try? await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try await controlSocket.eventLoopGroup.shutdownGracefully()
|
||||||
|
}
|
||||||
|
|
||||||
func testInitializerPropagatesControlSocketCreationFailure() async throws {
|
func testInitializerPropagatesControlSocketCreationFailure() async throws {
|
||||||
let temporaryDirectory = try makeTemporaryDirectory()
|
let temporaryDirectory = try makeTemporaryDirectory()
|
||||||
let originalDirectory = FileManager.default.currentDirectoryPath
|
let originalDirectory = FileManager.default.currentDirectoryPath
|
||||||
@@ -94,3 +177,20 @@ final class ControlSocketTests: XCTestCase {
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class AcceptErrorObserver: ChannelDuplexHandler {
|
||||||
|
typealias InboundIn = Channel
|
||||||
|
typealias OutboundIn = ByteBuffer
|
||||||
|
|
||||||
|
var readCount = 0
|
||||||
|
var errors: [Error] = []
|
||||||
|
|
||||||
|
func read(context: ChannelHandlerContext) {
|
||||||
|
readCount += 1
|
||||||
|
context.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||||
|
errors.append(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,4 +13,12 @@ final class DockerConfigTests: XCTestCase {
|
|||||||
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAuthPasswordWithColons() throws {
|
||||||
|
let auth = Data("user:token:with:colons".utf8).base64EncodedString()
|
||||||
|
let credentials = try XCTUnwrap(DockerAuthConfig(auth: auth).decodeCredentials())
|
||||||
|
|
||||||
|
XCTAssertEqual(credentials.0, "user")
|
||||||
|
XCTAssertEqual(credentials.1, "token:with:colons")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,38 @@ final class OCIManifestTests: XCTestCase {
|
|||||||
XCTAssertNil(manifest.diskBlockCount())
|
XCTAssertNil(manifest.diskBlockCount())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testOCIConfigIncludesRootFS() throws {
|
||||||
|
let config = OCIConfig(architecture: .arm64, os: .darwin, config: .init(Labels: [diskFormatLabel: "raw"]))
|
||||||
|
let json = try JSONSerialization.jsonObject(with: config.toJSON()) as! [String: Any]
|
||||||
|
|
||||||
|
let rootfs = try XCTUnwrap(json["rootfs"] as? [String: Any])
|
||||||
|
XCTAssertEqual(rootfs["type"] as? String, "layers")
|
||||||
|
XCTAssertEqual(rootfs["diff_ids"] as? [String], [])
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
String(decoding: try config.toJSON(), as: UTF8.self),
|
||||||
|
#"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"darwin","rootfs":{"diff_ids":[],"type":"layers"}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOCIConfigWithoutRootFSDecodes() throws {
|
||||||
|
// OCI config as pushed by Tart 2.40.1 and earlier
|
||||||
|
let json = #"{"architecture":"arm64","config":{"Labels":{"org.cirruslabs.tart.disk.format":"raw"}},"os":"linux"}"#
|
||||||
|
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: Data(json.utf8))
|
||||||
|
|
||||||
|
XCTAssertEqual(config.architecture, .arm64)
|
||||||
|
XCTAssertEqual(config.os, .linux)
|
||||||
|
XCTAssertEqual(config.config?.Labels, [diskFormatLabel: "raw"])
|
||||||
|
XCTAssertNil(config.rootfs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOCIConfigRootFSRoundTrips() throws {
|
||||||
|
let config = try Config.jsonDecoder().decode(OCIConfig.self, from: OCIConfig().toJSON())
|
||||||
|
|
||||||
|
XCTAssertEqual(config.rootfs?.type, "layers")
|
||||||
|
XCTAssertEqual(config.rootfs?.diffIDs, [])
|
||||||
|
}
|
||||||
|
|
||||||
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
|
private func assertManifestError(_ expected: OCIManifestValidationError, diskDescriptors: [OCIManifestLayer]) {
|
||||||
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
|
XCTAssertThrowsError(try manifest(diskDescriptors: diskDescriptors).tartDiskRepresentation()) { error in
|
||||||
XCTAssertEqual(error as? OCIManifestValidationError, expected)
|
XCTAssertEqual(error as? OCIManifestValidationError, expected)
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
|
func testFractionalIssuedAt() throws {
|
||||||
|
for timestamp in ["1970-01-01T00:00:00.125Z", "1970-01-01T01:30:00.125+01:30", "1970-01-01T00:00:00.125000000Z"] {
|
||||||
|
let data = Data("{\"token\":\"test-token\",\"expires_in\":3600,\"issued_at\":\"\(timestamp)\"}".utf8)
|
||||||
|
let response = try TokenResponse.parse(fromData: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.issuedAt!.timeIntervalSince1970, 0.125, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(response.tokenExpiresAt.timeIntervalSince1970, 3600.125, accuracy: 0.0001)
|
||||||
|
XCTAssertFalse(response.isValid())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
var expectedTokenExpiresAtRange = DateInterval()
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for range 2 {
|
for range 2 {
|
||||||
_, stderr, err := tart.Tart(t, "clone", "source", "destination")
|
_, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination")
|
||||||
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
|
if err == nil || !strings.Contains(strings.ToLower(stderr), "running") {
|
||||||
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
|
t.Fatalf("clone must reject the running destination: %v: %s", err, stderr)
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ func TestClonePreservesRunningDestination(t *testing.T) {
|
|||||||
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
|
if err := syscall.FcntlFlock(held.Fd(), syscall.F_SETLK, &lock); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, stderr, err := tart.Tart(t, "clone", "source", "destination"); err != nil {
|
if _, stderr, err := tart.Tart(t, "clone", "--overwrite", "source", "destination"); err != nil {
|
||||||
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
|
t.Fatalf("clone after shutdown: %v: %s", err, stderr)
|
||||||
}
|
}
|
||||||
currentInfo, err := os.Stat(config)
|
currentInfo, err := os.Stat(config)
|
||||||
|
|||||||
Reference in New Issue
Block a user