Compare commits

...
7 Commits
Author SHA1 Message Date
Nikolay Edigaryev 8539b8faae Delay Sentry initialization until after we parse the CLI arguments (#1085) 2025-05-30 17:24:19 +04:00
Nikolay Edigaryev 71159373e5 tart run: allow "--dir" with "--suspendable" (#1082) 2025-05-30 17:24:09 +04:00
Fedor Korotkov 8248f19943 Update sentry (#1079) 2025-05-28 23:06:10 +00:00
fedor 1cbc1e2cda Suspendable VMs now support consoles 2025-05-28 17:09:54 -04:00
Nikolay Edigaryev 0187834c34 tart exec: explain that Tart Guest Agent is required (#1078)
* tart exec: explain that Tart Guest Agent is required

Also handle decrease the connection timeout to 1 second
and provide a hint to the user.

* execute() can be made private

* Include error.localizedDescription
2025-05-27 12:57:56 +04:00
Nikolay Edigaryev dfbdb5559c Introduce "tart exec" command as an alternative to SSH (#1074)
* Introduce "tart exec" command as an alternative to SSH

* Simplify control socket machinery by using NIO async/await primitives

* No reason to print the "vm" object directly, just refer to it as "VM"

* Log to Apple’s Unified Logging System
2025-05-22 17:28:14 +04:00
Nikolay Edigaryev 40ab5c3af4 Fix unescaped commas in generated ArgumentParser completions (#1066)
* Fix unescaped commas in generated ArgumentParser completions

* Improve completion hints
2025-05-06 14:24:43 +04:00
15 changed files with 554 additions and 59 deletions
+120 -3
View File
@@ -1,5 +1,5 @@
{ {
"originHash" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748", "originHash" : "90af6efa7ed1bbb0cd6985eb109c1e265a223a697b7fbaae2453206a892180e7",
"pins" : [ "pins" : [
{ {
"identity" : "antlr4", "identity" : "antlr4",
@@ -10,6 +10,24 @@
"version" : "4.13.2" "version" : "4.13.2"
} }
}, },
{
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-dfeb75ad2b39.1/cirruslabs_tart-guest-agent_apple_swift.git",
"state" : {
"revision" : "3e13bec2dd36788e80a2e5a2022d44d4a1f373cf",
"version" : "1.28.2-00000000000000-dfeb75ad2b39.1"
}
},
{
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
"kind" : "remoteSourceControl",
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-dfeb75ad2b39.1/cirruslabs_tart-guest-agent_grpc_swift.git",
"state" : {
"branch" : "1.24.2-00000000000000-dfeb75ad2b39.1",
"revision" : "5b6ff43b580fe435f0a174e137e2b197759a7170"
}
},
{ {
"identity" : "dynamic", "identity" : "dynamic",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -19,6 +37,15 @@
"revision" : "772883073d044bc754d401cabb6574624eb3778f" "revision" : "772883073d044bc754d401cabb6574624eb3778f"
} }
}, },
{
"identity" : "grpc-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift.git",
"state" : {
"revision" : "8c5e99d0255c373e0330730d191a3423c57373fb",
"version" : "1.24.2"
}
},
{ {
"identity" : "semaphore", "identity" : "semaphore",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -33,8 +60,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa", "location" : "https://github.com/getsentry/sentry-cocoa",
"state" : { "state" : {
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a", "revision" : "65b3d2a7608685e8d4a37c68fa2c64f28d0b537e",
"version" : "8.36.0" "version" : "8.51.1"
} }
}, },
{ {
@@ -64,6 +91,33 @@
"version" : "1.2.0" "version" : "1.2.0"
} }
}, },
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "671108c96644956dddcd89dd59c203dcdb36cec7",
"version" : "1.1.4"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
"version" : "1.4.0"
}
},
{ {
"identity" : "swift-log", "identity" : "swift-log",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -73,6 +127,51 @@
"version" : "1.6.1" "version" : "1.6.1"
} }
}, },
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "34d486b01cd891297ac615e40d5999536a1e138d",
"version" : "2.83.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "f1f6f772198bee35d99dd145f1513d8581a54f2c",
"version" : "1.26.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0",
"version" : "1.36.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7",
"version" : "2.31.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
"version" : "1.24.0"
}
},
{ {
"identity" : "swift-numerics", "identity" : "swift-numerics",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -82,6 +181,15 @@
"version" : "1.0.2" "version" : "1.0.2"
} }
}, },
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "ebc7251dd5b37f627c93698e4374084d98409633",
"version" : "1.28.2"
}
},
{ {
"identity" : "swift-retry", "identity" : "swift-retry",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -100,6 +208,15 @@
"version" : "1.8.0" "version" : "1.8.0"
} }
}, },
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1",
"version" : "1.4.2"
}
},
{ {
"identity" : "swift-xattr", "identity" : "swift-xattr",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
+5 -1
View File
@@ -17,13 +17,15 @@ let package = Package(
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"), .package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"), .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.51.1"),
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"), .package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.8.0"),
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"), .package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"), .package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"), .package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"), .package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", .upToNextMajor(from: "1.24.2")),
.package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-dfeb75ad2b39.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-dfeb75ad2b39.1"),
], ],
targets: [ targets: [
.executableTarget(name: "tart", dependencies: [ .executableTarget(name: "tart", dependencies: [
@@ -40,6 +42,8 @@ let package = Package(
.product(name: "Semaphore", package: "Semaphore"), .product(name: "Semaphore", package: "Semaphore"),
.product(name: "DMRetry", package: "swift-retry"), .product(name: "DMRetry", package: "swift-retry"),
.product(name: "XAttr", package: "swift-xattr"), .product(name: "XAttr", package: "swift-xattr"),
.product(name: "GRPC", package: "grpc-swift"),
.product(name: "Cirruslabs_TartGuestAgent_Grpc_Swift", package: "cirruslabs_tart-guest-agent_grpc_swift"),
], exclude: [ ], exclude: [
"OCI/Reference/Makefile", "OCI/Reference/Makefile",
"OCI/Reference/Reference.g4", "OCI/Reference/Reference.g4",
+1 -1
View File
@@ -10,7 +10,7 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path")) @Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"), completion: .file())
var fromIPSW: String? var fromIPSW: String?
@Flag(help: "create a Linux VM") @Flag(help: "create a Linux VM")
+181
View File
@@ -0,0 +1,181 @@
import ArgumentParser
import Foundation
import NIOPosix
import GRPC
import Cirruslabs_TartGuestAgent_Grpc_Swift
struct ExecCustomExitCodeError: Error {
let exitCode: Int32
}
struct Exec: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM", discussion: """
Requires Tart Guest Agent running in a guest VM.
Note that all non-vanilla Cirrus Labs VM images already have the Tart Guest Agent installed.
""")
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
var name: String
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
var interactive: Bool = false
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
var tty: Bool = false
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
var command: [String]
func run() async throws {
// We only have withThrowingDiscardingTaskGroup available starting from macOS 14
if #unavailable(macOS 14) {
throw RuntimeError.Generic("\"tart exec\" is only available on macOS 14 (Sonoma) or newer")
}
// Open VM's directory
let vmDir = try VMStorageLocal().open(name)
// Ensure that the VM is running
if try !vmDir.running() {
throw RuntimeError.VMNotRunning(name)
}
// Create a gRPC channel connected to the VM's control socket
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer {
try! group.syncShutdownGracefully()
}
let channel = try GRPCChannelPool.with(
target: .unixDomainSocket(vmDir.controlSocketURL.path()),
transportSecurity: .plaintext,
eventLoopGroup: group,
)
defer {
try! channel.close().wait()
}
// Switch controlling terminal into raw mode when remote pseudo-terminal is requested
var state: State? = nil
if tty && Term.IsTerminal() {
state = try Term.MakeRaw()
}
defer {
// Restore terminal to its initial state
if let state {
try! Term.Restore(state)
}
}
// Execute a command in a running VM
do {
try await execute(channel)
} catch let error as GRPCConnectionPoolError {
throw RuntimeError.Generic("Failed to connect to the VM using its control socket: \(error.localizedDescription), is the Tart Guest Agent running?")
}
}
private func execute(_ channel: GRPCChannel) async throws {
let agentAsyncClient = AgentAsyncClient(channel: channel)
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
let execCall = agentAsyncClient.makeExecCall(callOptions: callOptions)
try await execCall.requestStream.send(.with {
$0.type = .command(.with {
$0.name = command[0]
$0.args = Array(command.dropFirst(1))
$0.interactive = interactive
$0.tty = tty
$0.terminalSize = .with {
let (width, height) = try! Term.GetSize()
$0.cols = UInt32(width)
$0.rows = UInt32(height)
}
})
})
// Process command events and optionally send our standard input and/or terminal dimensions
try await withThrowingTaskGroup { group in
// Stream host's standard input if interactive mode is enabled
if interactive {
let stdinStream = AsyncStream<Data> { continuation in
let handle = FileHandle.standardInput
handle.readabilityHandler = { handle in
let data = handle.availableData
continuation.yield(data)
if data.isEmpty {
continuation.finish()
}
}
}
group.addTask {
for await stdinData in stdinStream {
try await execCall.requestStream.send(.with {
$0.type = .standardInput(.with {
$0.data = stdinData
})
})
}
}
}
// Stream host's terminal dimensions if pseudo-terminal is requested
signal(SIGWINCH, SIG_IGN)
let sigwinchSrc = DispatchSource.makeSignalSource(signal: SIGWINCH)
sigwinchSrc.activate()
if tty {
let terminalDimensionsStream = AsyncStream { continuation in
sigwinchSrc.setEventHandler {
continuation.yield(try! Term.GetSize())
}
}
group.addTask {
for await (width, height) in terminalDimensionsStream {
try await execCall.requestStream.send(.with {
$0.type = .terminalResize(.with {
$0.cols = UInt32(width)
$0.rows = UInt32(height)
})
})
}
}
}
// Process command events
group.addTask {
for try await response in execCall.responseStream {
switch response.type {
case .standardOutput(let ioChunk):
try FileHandle.standardOutput.write(contentsOf: ioChunk.data)
case .standardError(let ioChunk):
try FileHandle.standardError.write(contentsOf: ioChunk.data)
case .exit(let exit):
throw ExecCustomExitCodeError(exitCode: exit.code)
default:
// Unknown event, do nothing
continue
}
}
}
while !group.isEmpty {
do {
try await group.next()
} catch {
group.cancelAll()
throw error
}
}
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ struct Export: AsyncParsableCommand {
@Argument(help: "Source VM name.", completion: .custom(completeMachines)) @Argument(help: "Source VM name.", completion: .custom(completeMachines))
var name: String var name: String
@Argument(help: "Path to the destination file.") @Argument(help: "Path to the destination file.", completion: .file())
var path: String? var path: String?
func run() async throws { func run() async throws {
+2 -2
View File
@@ -4,10 +4,10 @@ import Foundation
struct Import: AsyncParsableCommand { struct Import: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file") static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
@Argument(help: "Path to a file created with \"tart export\".") @Argument(help: "Path to a file created with \"tart export\".", completion: .file())
var path: String var path: String
@Argument(help: "Destination VM name.") @Argument(help: "Destination VM name.", completion: .custom(completeLocalMachines))
var name: String var name: String
func validate() throws { func validate() throws {
+1 -1
View File
@@ -18,7 +18,7 @@ struct List: AsyncParsableCommand {
@Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci).")) @Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
var source: String? var source: String?
@Option(help: "Output format: text or json") @Option(help: "Output format: text or json", completion: .list(["text", "json"]))
var format: Format = .text var format: Format = .text
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names.")) @Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
+1 -1
View File
@@ -7,7 +7,7 @@ import SwiftDate
struct Prune: AsyncParsableCommand { struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs") static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs.")) @Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."), completion: .list(["caches", "vms"]))
var entries: String = "caches" var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago", @Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
+19 -8
View File
@@ -81,7 +81,7 @@ struct Run: AsyncParsableCommand {
@Option(help: ArgumentHelp( @Option(help: ArgumentHelp(
"Attach an externally created serial console", "Attach an externally created serial console",
discussion: "Alternative to `--serial` flag for programmatic integrations." discussion: "Alternative to `--serial` flag for programmatic integrations."
)) ), completion: .file())
var serialPath: String? var serialPath: String?
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private)) @Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
@@ -116,8 +116,12 @@ struct Run: AsyncParsableCommand {
#endif #endif
var vncExperimental: Bool = false var vncExperimental: Bool = false
// Note that the valueName here should really be "path[:options]" instead of just "path",
// see ArgumentParser issue[1] for more details.
//
// [1]: https://github.com/apple/swift-argument-parser/issues/761
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only and synchronization options (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none") Additional disk attachments with an optional read-only and synchronization options in the form of path[:options] (e.g. --disk="disk.bin", --disk="ubuntu.iso:ro", --disk="/dev/disk0", --disk "ghcr.io/cirruslabs/xcode:16.0:ro" or --disk="nbd://localhost:10809/myDisk:sync=none")
""", discussion: """ """, discussion: """
The disk attachment can be a: The disk attachment can be a:
@@ -139,7 +143,7 @@ struct Run: AsyncParsableCommand {
To work around this pass TART_HOME explicitly: To work around this pass TART_HOME explicitly:
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0 sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
""", valueName: "path[:options]")) """, valueName: "path"), completion: .file())
var disk: [String] = [] var disk: [String] = []
#if arch(arm64) #if arch(arm64)
@@ -158,7 +162,11 @@ struct Run: AsyncParsableCommand {
#endif #endif
var rosettaTag: String? var rosettaTag: String?
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """ // Note that the valueName here should really be "[name:]path[:options]" instead of just "path",
// see ArgumentParser issue[1] for more details.
//
// [1]: https://github.com/apple/swift-argument-parser/issues/761
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options in the form of [name:]path[:options] (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too. Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
Options are comma-separated and are as follows: Options are comma-separated and are as follows:
@@ -170,7 +178,7 @@ struct Run: AsyncParsableCommand {
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux. Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively. In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:options]")) """, valueName: "path"), completion: .directory)
var dir: [String] = [] var dir: [String] = []
@Flag(help: ArgumentHelp("Enable nested virtualization if possible")) @Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
@@ -312,9 +320,6 @@ struct Run: AsyncParsableCommand {
if !(config.platform is PlatformSuspendable) { if !(config.platform is PlatformSuspendable) {
throw ValidationError("You can only suspend macOS VMs") throw ValidationError("You can only suspend macOS VMs")
} }
if dir.count > 0 {
throw ValidationError("Suspending VMs with shared directories is not supported")
}
if noTrackpad { if noTrackpad {
throw ValidationError("--no-trackpad cannot be used with --suspendable") throw ValidationError("--no-trackpad cannot be used with --suspendable")
@@ -481,6 +486,12 @@ struct Run: AsyncParsableCommand {
} }
} }
if #available(macOS 14, *) {
Task {
try await ControlSocket(vmDir.controlSocketURL).run()
}
}
try await vm!.run() try await vm!.run()
if let vncImpl = vncImpl { if let vncImpl = vncImpl {
+89
View File
@@ -0,0 +1,89 @@
import Foundation
import Network
import os.log
import NIO
import NIOPosix
@available(macOS 14, *)
class ControlSocket {
let controlSocketURL: URL
let vmPort: UInt32
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
let logger: os.Logger = os.Logger(subsystem: "org.cirruslabs.tart.control-socket", category: "network")
init(_ controlSocketURL: URL, vmPort: UInt32 = 8080) {
self.controlSocketURL = controlSocketURL
self.vmPort = vmPort
}
func run() async throws {
// Remove control socket file from previous "tart run" invocations,
// if any, otherwise we may get the "address already in use" error
try? FileManager.default.removeItem(atPath: controlSocketURL.path())
let serverChannel = try await ServerBootstrap(group: eventLoopGroup)
.bind(unixDomainSocketPath: controlSocketURL.path()) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
return try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await withThrowingDiscardingTaskGroup { group in
try await serverChannel.executeThenClose { serverInbound in
for try await clientChannel in serverInbound {
group.addTask {
try await self.handleClient(clientChannel)
}
}
}
}
}
func handleClient(_ clientChannel: NIOAsyncChannel<ByteBuffer, ByteBuffer>) async throws {
self.logger.info("received new control socket connection from a client")
try await clientChannel.executeThenClose { clientInbound, clientOutbound in
self.logger.info("dialing to VM on port \(self.vmPort)...")
do {
guard let vmConnection = try await vm?.connect(toPort: self.vmPort) else {
throw RuntimeError.VMSocketFailed(self.vmPort, "VM is not running")
}
self.logger.info("running control socket proxy")
let vmChannel = try await ClientBootstrap(group: eventLoopGroup).withConnectedSocket(vmConnection.fileDescriptor) { childChannel in
childChannel.eventLoop.makeCompletedFuture {
try NIOAsyncChannel<ByteBuffer, ByteBuffer>(
wrappingChannelSynchronously: childChannel
)
}
}
try await vmChannel.executeThenClose { (vmInbound, vmOutbound) in
try await withThrowingDiscardingTaskGroup { group in
// Proxy data from a client (e.g. "tart exec") to a VM
group.addTask {
for try await message in clientInbound {
try await vmOutbound.write(message)
}
}
// Proxy data from a VM to a client (e.g. "tart exec")
group.addTask {
for try await message in vmInbound {
try await clientOutbound.write(message)
}
}
}
}
self.logger.info("control socket client disconnected")
} catch (let error) {
self.logger.error("control socket connection failed: \(error)")
}
}
}
}
+41 -34
View File
@@ -18,6 +18,7 @@ struct Root: AsyncParsableCommand {
Login.self, Login.self,
Logout.self, Logout.self,
IP.self, IP.self,
Exec.self,
Pull.self, Pull.self,
Push.self, Push.self,
Import.self, Import.self,
@@ -30,39 +31,6 @@ struct Root: AsyncParsableCommand {
]) ])
public static func main() async throws { public static func main() async throws {
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
SentrySDK.configureScope { scope in
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
}
// Enrich future events with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
SentrySDK.configureScope { scope in
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
scope.setTag(value: value, key: key)
}
}
}
// Add commands that are only available on specific macOS versions // Add commands that are only available on specific macOS versions
if #available(macOS 14, *) { if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self) configuration.subcommands.append(Suspend.self)
@@ -82,10 +50,43 @@ struct Root: AsyncParsableCommand {
// Set line-buffered output for stdout // Set line-buffered output for stdout
setlinebuf(stdout) setlinebuf(stdout)
// Parse and run command
do { do {
// Parse command
var command = try parseAsRoot() var command = try parseAsRoot()
// Initialize Sentry
if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = CI.release
options.tracesSampleRate = Float(
ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
) as NSNumber?
// By default only 5XX are captured
// Let's capture everything but 401 (unauthorized)
options.enableCaptureFailedRequests = true
options.failedRequestStatusCodes = [
HttpStatusCodeRange(min: 400, max: 400),
HttpStatusCodeRange(min: 402, max: 599)
]
}
}
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
SentrySDK.configureScope { scope in
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
}
// Enrich future events with Cirrus CI-specific tags
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
SentrySDK.configureScope { scope in
for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
scope.setTag(value: value, key: key)
}
}
}
// Run garbage-collection before each command (shouldn't take too long) // Run garbage-collection before each command (shouldn't take too long)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){ if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do { do {
@@ -95,12 +96,18 @@ struct Root: AsyncParsableCommand {
} }
} }
// Run command
if var asyncCommand = command as? AsyncParsableCommand { if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run() try await asyncCommand.run()
} else { } else {
try command.run() try command.run()
} }
} catch { } catch {
// Not an error, just a custom exit code from "tart exec"
if let execCustomExitCodeError = error as? ExecCustomExitCodeError {
Foundation.exit(execCustomExitCodeError.exitCode)
}
// Capture the error into Sentry // Capture the error into Sentry
SentrySDK.capture(error: error) SentrySDK.capture(error: error)
SentrySDK.flush(timeout: 2.seconds.timeInterval) SentrySDK.flush(timeout: 2.seconds.timeInterval)
+60
View File
@@ -0,0 +1,60 @@
import Foundation
import System
struct State {
fileprivate let termios: termios
}
class Term {
static func IsTerminal() -> Bool {
var termios = termios()
return tcgetattr(FileHandle.standardInput.fileDescriptor, &termios) != -1
}
static func MakeRaw() throws -> State {
var termiosOrig = termios()
var ret = tcgetattr(FileHandle.standardInput.fileDescriptor, &termiosOrig)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to retrieve terminal parameters: \(details)")
}
var termiosRaw = termiosOrig
cfmakeraw(&termiosRaw)
ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termiosRaw)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
return State(termios: termiosOrig)
}
static func Restore(_ state: State) throws {
var termios = state.termios
let ret = tcsetattr(FileHandle.standardInput.fileDescriptor, TCSANOW, &termios)
if ret == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to set terminal parameters: \(details)")
}
}
static func GetSize() throws -> (width: UInt16, height: UInt16) {
var winsize = winsize()
guard ioctl(STDOUT_FILENO, TIOCGWINSZ, &winsize) != -1 else {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.TerminalOperationFailed("failed to get terminal size: \(details)")
}
return (width: winsize.ws_col, height: winsize.ws_row)
}
}
+21 -7
View File
@@ -248,6 +248,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@MainActor
func connect(toPort: UInt32) async throws -> VZVirtioSocketConnection {
guard let socketDevice = virtualMachine.socketDevices.first else {
throw RuntimeError.VMSocketFailed(toPort, ", VM has no socket devices configured")
}
guard let virtioSocketDevice = socketDevice as? VZVirtioSocketDevice else {
throw RuntimeError.VMSocketFailed(toPort, ", expected VM's first socket device to have a type of VZVirtioSocketDevice, got \(type(of: socketDevice)) instead")
}
return try await virtioSocketDevice.connect(toPort: toPort)
}
func run() async throws { func run() async throws {
do { do {
try await sema.waitUnlessCancelled() try await sema.waitUnlessCancelled()
@@ -399,15 +412,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// //
// A dummy console device useful for implementing // A dummy console device useful for implementing
// host feature checks in the guest agent software. // host feature checks in the guest agent software.
if !suspendable { let consolePort = VZVirtioConsolePortConfiguration()
let consolePort = VZVirtioConsolePortConfiguration() consolePort.name = "tart-version-\(CI.version)"
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration() let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice) configuration.consoleDevices.append(consoleDevice)
}
// Socket device
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
try configuration.validate() try configuration.validate()
+3
View File
@@ -26,6 +26,9 @@ struct VMDirectory: Prunable {
var manifestURL: URL { var manifestURL: URL {
baseURL.appendingPathComponent("manifest.json") baseURL.appendingPathComponent("manifest.json")
} }
var controlSocketURL: URL {
baseURL.appendingPathComponent("control.sock")
}
var explicitlyPulledMark: URL { var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled") baseURL.appendingPathComponent(".explicitly-pulled")
+9
View File
@@ -49,6 +49,7 @@ extension Error {
} }
enum RuntimeError : Error { enum RuntimeError : Error {
case Generic(_ message: String)
case VMConfigurationError(_ message: String) case VMConfigurationError(_ message: String)
case VMDoesNotExist(name: String) case VMDoesNotExist(name: String)
case VMMissingFiles(_ message: String) case VMMissingFiles(_ message: String)
@@ -75,6 +76,8 @@ enum RuntimeError : Error {
case SuspendFailed(_ message: String) case SuspendFailed(_ message: String)
case PullFailed(_ message: String) case PullFailed(_ message: String)
case VirtualMachineLimitExceeded(_ hint: String) case VirtualMachineLimitExceeded(_ hint: String)
case VMSocketFailed(_ port: UInt32, _ explanation: String)
case TerminalOperationFailed(_ message: String)
} }
protocol HasExitCode { protocol HasExitCode {
@@ -84,6 +87,8 @@ protocol HasExitCode {
extension RuntimeError : CustomStringConvertible { extension RuntimeError : CustomStringConvertible {
public var description: String { public var description: String {
switch self { switch self {
case .Generic(let message):
return message
case .VMConfigurationError(let message): case .VMConfigurationError(let message):
return message return message
case .VMDoesNotExist(let name): case .VMDoesNotExist(let name):
@@ -136,6 +141,10 @@ extension RuntimeError : CustomStringConvertible {
return message return message
case .VirtualMachineLimitExceeded(let hint): case .VirtualMachineLimitExceeded(let hint):
return "The number of VMs exceeds the system limit\(hint)" return "The number of VMs exceeds the system limit\(hint)"
case .VMSocketFailed(let port, let explanation):
return "Failed to establish a VM socket connection to port \(port): \(explanation)"
case .TerminalOperationFailed(let message):
return message
} }
} }
} }