diff --git a/Package.resolved b/Package.resolved index db15534..f9b79b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748", + "originHash" : "fe99b8634d39cad3971bde2180657a2bf711968e2e9cf5e3823bc51ea1530663", "pins" : [ { "identity" : "antlr4", @@ -10,6 +10,24 @@ "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", "kind" : "remoteSourceControl", @@ -19,6 +37,15 @@ "revision" : "772883073d044bc754d401cabb6574624eb3778f" } }, + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift.git", + "state" : { + "revision" : "8c5e99d0255c373e0330730d191a3423c57373fb", + "version" : "1.24.2" + } + }, { "identity" : "semaphore", "kind" : "remoteSourceControl", @@ -64,6 +91,33 @@ "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", "kind" : "remoteSourceControl", @@ -73,6 +127,51 @@ "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", "kind" : "remoteSourceControl", @@ -82,6 +181,15 @@ "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", "kind" : "remoteSourceControl", @@ -100,6 +208,15 @@ "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", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ed82911..bed3be8 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,8 @@ let package = Package( .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/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: [ .executableTarget(name: "tart", dependencies: [ @@ -40,6 +42,8 @@ let package = Package( .product(name: "Semaphore", package: "Semaphore"), .product(name: "DMRetry", package: "swift-retry"), .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: [ "OCI/Reference/Makefile", "OCI/Reference/Reference.g4", diff --git a/Sources/tart/Commands/Exec.swift b/Sources/tart/Commands/Exec.swift new file mode 100644 index 0000000..65ef02b --- /dev/null +++ b/Sources/tart/Commands/Exec.swift @@ -0,0 +1,168 @@ +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") + + @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 + let agentAsyncClient = AgentAsyncClient(channel: channel) + let execCall = agentAsyncClient.makeExecCall() + + 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 { 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 + } + } + } + } +} diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index e21a7dd..b8985a2 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -489,6 +489,12 @@ struct Run: AsyncParsableCommand { } } + if #available(macOS 14, *) { + Task { + try await ControlSocket(vmDir.controlSocketURL).run() + } + } + try await vm!.run() if let vncImpl = vncImpl { diff --git a/Sources/tart/ControlSocket.swift b/Sources/tart/ControlSocket.swift new file mode 100644 index 0000000..443a7db --- /dev/null +++ b/Sources/tart/ControlSocket.swift @@ -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( + 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) 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( + 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)") + } + } + } +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 3e37831..19eb87b 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -18,6 +18,7 @@ struct Root: AsyncParsableCommand { Login.self, Logout.self, IP.self, + Exec.self, Pull.self, Push.self, Import.self, @@ -101,6 +102,11 @@ struct Root: AsyncParsableCommand { try command.run() } } 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 SentrySDK.capture(error: error) SentrySDK.flush(timeout: 2.seconds.timeInterval) diff --git a/Sources/tart/Term.swift b/Sources/tart/Term.swift new file mode 100644 index 0000000..a5f1f9e --- /dev/null +++ b/Sources/tart/Term.swift @@ -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) + } +} diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift index 159b6ae..fafad8a 100644 --- a/Sources/tart/VM.swift +++ b/Sources/tart/VM.swift @@ -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 { do { try await sema.waitUnlessCancelled() @@ -409,6 +422,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { configuration.consoleDevices.append(consoleDevice) } + // Socket device + configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()] + try configuration.validate() return configuration diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 309e4a2..b198bb9 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -26,6 +26,9 @@ struct VMDirectory: Prunable { var manifestURL: URL { baseURL.appendingPathComponent("manifest.json") } + var controlSocketURL: URL { + baseURL.appendingPathComponent("control.sock") + } var explicitlyPulledMark: URL { baseURL.appendingPathComponent(".explicitly-pulled") diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 9f26557..788e81d 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -49,6 +49,7 @@ extension Error { } enum RuntimeError : Error { + case Generic(_ message: String) case VMConfigurationError(_ message: String) case VMDoesNotExist(name: String) case VMMissingFiles(_ message: String) @@ -75,6 +76,8 @@ enum RuntimeError : Error { case SuspendFailed(_ message: String) case PullFailed(_ message: String) case VirtualMachineLimitExceeded(_ hint: String) + case VMSocketFailed(_ port: UInt32, _ explanation: String) + case TerminalOperationFailed(_ message: String) } protocol HasExitCode { @@ -84,6 +87,8 @@ protocol HasExitCode { extension RuntimeError : CustomStringConvertible { public var description: String { switch self { + case .Generic(let message): + return message case .VMConfigurationError(let message): return message case .VMDoesNotExist(let name): @@ -136,6 +141,10 @@ extension RuntimeError : CustomStringConvertible { return message case .VirtualMachineLimitExceeded(let 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 } } }