Compare commits

...
9 Commits
Author SHA1 Message Date
Brendan Shanks 12501370cc Support getting the MAC address, and setting an arbitrary MAC address (#1327)
* tart get: Report MAC address

* tart set: Support setting an arbitrary MAC address
2026-10-13 16:57:49 +00:00
Sylvester Kaczmarek 7d39f4cf68 Parse fractional timestamps in registry token responses (#1361)
Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com>
2026-10-13 16:54:22 +00:00
edi-oai 8364f4ccac tart run: introduce "--net-fd" for better network composability (#1371)
* tart run: introduce "--net-fd" for better network composability

* tart run: allow standard input/output/error FDs for --net-fd
2026-10-13 17:30:15 +01:00
12 ef925e433d Keep display refit setting when tart set changes other options (#1369) 2026-10-13 12:25:07 +00:00
12 37af26a810 Allow colons in Docker config auth passwords (#1370) 2026-10-13 12:18:52 +00:00
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
17 changed files with 425 additions and 21 deletions
+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")
}
}
}
+2
View File
@@ -9,6 +9,7 @@ fileprivate struct VMInfo: Encodable {
let DiskFormat: String
let Size: HumanReadableByteCount
let Display: String
let MACAddress: String
let Running: Bool
let State: String
}
@@ -38,6 +39,7 @@ struct Get: AsyncParsableCommand {
String(format: "%.3f", Float($0) / 1000 / 1000 / 1000)
},
Display: vmConfig.display.description,
MACAddress: vmConfig.macAddress.string,
Running: try vmDir.running(),
State: try vmDir.state().rawValue
)
+22 -4
View File
@@ -187,6 +187,17 @@ struct Run: AsyncParsableCommand {
""", valueName: "interface name"))
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",
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).
@@ -332,9 +343,10 @@ struct Run: AsyncParsableCommand {
if netBridged.count > 0 { netFlags += 1 }
if netSoftnet { netFlags += 1 }
if netHost { netFlags += 1 }
if netFd != nil { 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 {
@@ -428,7 +440,7 @@ struct Run: AsyncParsableCommand {
try vmDir.regenerateMACAddress()
}
if (netSoftnet || netHost) && isInteractiveSession() {
if netSoftnet && isInteractiveSession() {
try Softnet.configureSUIDBitIfNeeded()
}
@@ -703,9 +715,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 {
@@ -723,6 +737,10 @@ struct Run: AsyncParsableCommand {
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
}
if let netFd = netFd {
return try NetworkFD(fd: netFd)
}
return nil
}
+18 -1
View File
@@ -20,6 +20,9 @@ struct Set: AsyncParsableCommand {
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
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."))
var randomMAC: Bool = false
@@ -47,6 +50,10 @@ struct Set: AsyncParsableCommand {
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)
if let cpu = cpu {
@@ -67,7 +74,17 @@ struct Set: AsyncParsableCommand {
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 {
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
+19
View File
@@ -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)
}
}
}
@@ -104,13 +104,13 @@ struct DockerAuthConfig: Codable {
guard let data = Data(base64Encoded: authBase64) else {
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
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
return (String(components[0]), String(components[1]))
}
}
@@ -18,6 +18,11 @@ struct Bootptab {
}
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
+37
View File
@@ -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
}
}
+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))"
}
}
}
+11 -11
View File
@@ -24,7 +24,11 @@ class Softnet: Network {
}
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)
@@ -50,20 +54,16 @@ class Softnet: Network {
}
}
static func validateControlFD(_ fd: Int32) throws {
guard fd > STDERR_FILENO else {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be greater than 2")
}
static func validateControlFD(_ fd: Int32, type: Int32 = SOCK_STREAM) throws {
var socketType: Int32 = 0
var socketTypeLength = socklen_t(MemoryLayout<Int32>.size)
guard getsockopt(fd, SOL_SOCKET, SO_TYPE, &socketType, &socketTypeLength) == 0 else {
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 {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
guard socketType == type else {
throw RuntimeError.Generic("must be a \(type == SOCK_STREAM ? "stream" : "datagram") socket")
}
var peerAddress = sockaddr_storage()
@@ -75,11 +75,11 @@ class Softnet: Network {
}
guard result == 0 else {
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 {
throw SoftnetError.InitializationFailed(why: "Softnet control file descriptor must be a Unix stream socket")
throw RuntimeError.Generic("must be a Unix socket")
}
}
+4 -1
View File
@@ -69,12 +69,15 @@ struct TokenResponse: Decodable, Authentication {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime]
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
let fractionalDateFormatter = ISO8601DateFormatter()
fractionalDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
fractionalDateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
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)
+31
View File
@@ -3,6 +3,37 @@ 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)
@@ -122,6 +122,60 @@ final class CommandBehaviorTests: XCTestCase {
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")
@@ -152,6 +206,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 {
try withTemporaryTartHome {
let storage = try VMStorageOCI()
@@ -208,6 +278,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,
+100
View File
@@ -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)
}
}
+8
View File
@@ -13,4 +13,12 @@ final class DockerConfigTests: XCTestCase {
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
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")
}
}
+11
View File
@@ -2,6 +2,17 @@ import XCTest
@testable import tart
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 {
var expectedTokenExpiresAtRange = DateInterval()
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
+2 -2
View File
@@ -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)