mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-17 19:36:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e443cfa9a2 | ||
|
|
e35c13425e | ||
|
|
0debec1266 | ||
|
|
294c5fc5e5 | ||
|
|
99777b6740 | ||
|
|
a2972aa4d9 | ||
|
|
3a6c5fb81d | ||
|
|
5793935317 | ||
|
|
8dc8b644b2 | ||
|
|
b625c04131 | ||
|
|
a0c03dcce6 | ||
|
|
8539b8faae | ||
|
|
71159373e5 | ||
|
|
8248f19943 | ||
|
|
1cbc1e2cda | ||
|
|
0187834c34 |
+4
-2
@@ -83,8 +83,9 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro
|
- brew install go
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
|
- brew install --cask goreleaser/tap/goreleaser-pro
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
@@ -121,8 +122,9 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
- brew install go getsentry/tools/sentry-cli
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
|
- brew install --cask goreleaser/tap/goreleaser-pro
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ tart.xcodeproj/
|
|||||||
# AppCode
|
# AppCode
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# Swift
|
# Swift
|
||||||
.build/
|
.build/
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Table of Contents
|
|||||||
|
|
||||||
1. Code should follow camel case
|
1. Code should follow camel case
|
||||||
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||||
```
|
```
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "fe99b8634d39cad3971bde2180657a2bf711968e2e9cf5e3823bc51ea1530663",
|
"originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
@@ -13,19 +13,19 @@
|
|||||||
{
|
{
|
||||||
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
|
"identity" : "cirruslabs_tart-guest-agent_apple_swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-dfeb75ad2b39.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "3e13bec2dd36788e80a2e5a2022d44d4a1f373cf",
|
"revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf",
|
||||||
"version" : "1.28.2-00000000000000-dfeb75ad2b39.1"
|
"version" : "1.28.2-00000000000000-17d7dedafb88.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
|
"identity" : "cirruslabs_tart-guest-agent_grpc_swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-dfeb75ad2b39.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "1.24.2-00000000000000-dfeb75ad2b39.1",
|
"branch" : "1.24.2-00000000000000-17d7dedafb88.1",
|
||||||
"revision" : "5b6ff43b580fe435f0a174e137e2b197759a7170"
|
"revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
|
||||||
"version" : "1.5.0"
|
"version" : "1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+3
-3
@@ -10,14 +10,14 @@ let package = Package(
|
|||||||
.executable(name: "tart", targets: ["tart"])
|
.executable(name: "tart", targets: ["tart"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.6.1"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||||
.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"),
|
||||||
@@ -25,7 +25,7 @@ let package = Package(
|
|||||||
.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://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"),
|
.package(url: "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git", revision: "1.24.2-00000000000000-17d7dedafb88.1"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
|
||||||
tart run sequoia-base
|
tart run tahoe-base
|
||||||
```
|
```
|
||||||
|
|
||||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||||
var diskSize: UInt16 = 50
|
var diskSize: UInt16 = 50
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later"))
|
||||||
|
var diskFormat: DiskImageFormat = .raw
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil && !linux {
|
if fromIPSW == nil && !linux {
|
||||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||||
@@ -28,6 +31,11 @@ struct Create: AsyncParsableCommand {
|
|||||||
throw ValidationError("Only Linux VMs are supported on Intel!")
|
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Validate disk format support
|
||||||
|
if !diskFormat.isSupported {
|
||||||
|
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
|
|||||||
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if linux {
|
if linux {
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ struct ExecCustomExitCodeError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Exec: AsyncParsableCommand {
|
struct Exec: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM")
|
static var configuration = CommandConfiguration(abstract: "Execute a command in a running VM", discussion: """
|
||||||
|
Requires Tart Guest Agent running in a guest VM.
|
||||||
|
|
||||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
Note that all non-vanilla Cirrus Labs VM images already have the Tart Guest Agent installed.
|
||||||
var name: String
|
""")
|
||||||
|
|
||||||
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
|
@Flag(name: [.customShort("i")], help: "Attach host's standard input to a remote command")
|
||||||
var interactive: Bool = false
|
var interactive: Bool = false
|
||||||
@@ -20,6 +21,9 @@ struct Exec: AsyncParsableCommand {
|
|||||||
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
|
@Flag(name: [.customShort("t")], help: "Allocate a remote pseudo-terminal (PTY)")
|
||||||
var tty: Bool = false
|
var tty: Bool = false
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
|
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
|
||||||
var command: [String]
|
var command: [String]
|
||||||
|
|
||||||
@@ -66,6 +70,14 @@ struct Exec: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute a command in a running VM
|
// 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 agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||||
let execCall = agentAsyncClient.makeExecCall()
|
let execCall = agentAsyncClient.makeExecCall()
|
||||||
|
|
||||||
@@ -75,11 +87,13 @@ struct Exec: AsyncParsableCommand {
|
|||||||
$0.args = Array(command.dropFirst(1))
|
$0.args = Array(command.dropFirst(1))
|
||||||
$0.interactive = interactive
|
$0.interactive = interactive
|
||||||
$0.tty = tty
|
$0.tty = tty
|
||||||
$0.terminalSize = .with {
|
if tty {
|
||||||
let (width, height) = try! Term.GetSize()
|
$0.terminalSize = .with {
|
||||||
|
let (width, height) = try! Term.GetSize()
|
||||||
|
|
||||||
$0.cols = UInt32(width)
|
$0.cols = UInt32(width)
|
||||||
$0.rows = UInt32(height)
|
$0.rows = UInt32(height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -88,28 +102,61 @@ struct Exec: AsyncParsableCommand {
|
|||||||
try await withThrowingTaskGroup { group in
|
try await withThrowingTaskGroup { group in
|
||||||
// Stream host's standard input if interactive mode is enabled
|
// Stream host's standard input if interactive mode is enabled
|
||||||
if interactive {
|
if interactive {
|
||||||
let stdinStream = AsyncStream<Data> { continuation in
|
let stdinStream = AsyncThrowingStream<Data, Error> { continuation in
|
||||||
let handle = FileHandle.standardInput
|
let handle = FileHandle.standardInput
|
||||||
|
|
||||||
handle.readabilityHandler = { handle in
|
if isRegularFile(handle.fileDescriptor) {
|
||||||
let data = handle.availableData
|
// Standard input can be a regular file when input redirection (<) is used,
|
||||||
|
// in which case the handle won't receive any new readability events, so we
|
||||||
|
// just read the file normally here in chunks and consider done with it
|
||||||
|
//
|
||||||
|
// Ideally this is best handled by using non-blocking I/O, but Swift's
|
||||||
|
// standard library only offers inefficient bytes[1] property and SwiftNIO's
|
||||||
|
// NIOFileSystem doesn't seem to support opening raw file descriptors.
|
||||||
|
//
|
||||||
|
// [1]: https://developer.apple.com/documentation/foundation/filehandle/bytes
|
||||||
|
while true {
|
||||||
|
do {
|
||||||
|
let data = try handle.read(upToCount: 64 * 1024)
|
||||||
|
if let data = data {
|
||||||
|
continuation.yield(data)
|
||||||
|
} else {
|
||||||
|
continuation.finish()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (let error) {
|
||||||
|
continuation.finish(throwing: error)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
handle.readabilityHandler = { handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
|
||||||
continuation.yield(data)
|
if data.isEmpty {
|
||||||
|
continuation.finish()
|
||||||
if data.isEmpty {
|
} else {
|
||||||
continuation.finish()
|
continuation.yield(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
group.addTask {
|
group.addTask {
|
||||||
for await stdinData in stdinStream {
|
for try await stdinData in stdinStream {
|
||||||
try await execCall.requestStream.send(.with {
|
try await execCall.requestStream.send(.with {
|
||||||
$0.type = .standardInput(.with {
|
$0.type = .standardInput(.with {
|
||||||
$0.data = stdinData
|
$0.data = stdinData
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal EOF as we're done reading standard input
|
||||||
|
try await execCall.requestStream.send(.with {
|
||||||
|
$0.type = .standardInput(.with {
|
||||||
|
$0.data = Data()
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,3 +213,13 @@ struct Exec: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isRegularFile(_ fileDescriptor: Int32) -> Bool {
|
||||||
|
var stat = stat()
|
||||||
|
|
||||||
|
if fstat(fileDescriptor, &stat) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (stat.st_mode & S_IFMT) == S_IFREG
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
let CPU: Int
|
let CPU: Int
|
||||||
let Memory: UInt64
|
let Memory: UInt64
|
||||||
let Disk: Int
|
let Disk: Int
|
||||||
|
let DiskFormat: String
|
||||||
let Size: String
|
let Size: String
|
||||||
let Display: String
|
let Display: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
|
|||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
|
||||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import SystemConfiguration
|
|||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||||
case dhcp, arp
|
case dhcp, arp, agent
|
||||||
|
|
||||||
private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
|
private(set) static var allValueStrings: [String] = Self.allCases.map { "\($0)"}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct IP: AsyncParsableCommand {
|
struct IP: AsyncParsableCommand {
|
||||||
@@ -19,13 +19,11 @@ struct IP: AsyncParsableCommand {
|
|||||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||||
var wait: UInt16 = 0
|
var wait: UInt16 = 0
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
By default, Tart is using a "dhcp" resolver which parses the DHCP lease file on host and tries to find an entry containing the VM's MAC address. This method is fast and the most reliable, but only works for VMs are not using the bridged networking.\n
|
||||||
This method is fast and the most reliable but only returns local IP adresses.\n
|
Alternatively, Tart has an "arp" resolver which calls an external "arp" executable and parses it's output. This works for VMs using bridged networking and returns their IP, but when they generate enough network activity to populate the host's ARP table. Note that "arp" strategy won't work for VMs using the Softnet networking.\n
|
||||||
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
A third strategy, "agent" works in all cases reliably, but requires Guest agent for Tart VMs (https://github.com/cirruslabs/tart-guest-agent) to be installed inside of a VM.
|
||||||
In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
|
|
||||||
Note that `arp` strategy won't work for VMs using `--net-softnet`.
|
|
||||||
"""))
|
"""))
|
||||||
var resolver: IPResolutionStrategy = .dhcp
|
var resolver: IPResolutionStrategy = .dhcp
|
||||||
|
|
||||||
@@ -34,14 +32,16 @@ struct IP: AsyncParsableCommand {
|
|||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
|
||||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait, controlSocketURL: vmDir.controlSocketURL) else {
|
||||||
var message = "no IP address found"
|
var message = "no IP address found"
|
||||||
|
|
||||||
if try !vmDir.running() {
|
if try !vmDir.running() {
|
||||||
message += ", is your VM running?"
|
message += ", is your VM running?"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vmConfig.os == .linux && resolver == .arp) {
|
if (resolver == .agent) {
|
||||||
|
message += " (also make sure that Guest agent for Tart is running inside of a VM)"
|
||||||
|
} else if (vmConfig.os == .linux && resolver == .arp) {
|
||||||
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
print(ip)
|
print(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
|
static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0, controlSocketURL: URL? = nil) async throws -> IPv4Address? {
|
||||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
@@ -64,6 +64,14 @@ struct IP: AsyncParsableCommand {
|
|||||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
case .agent:
|
||||||
|
guard let controlSocketURL = controlSocketURL else {
|
||||||
|
throw RuntimeError.Generic("Cannot perform IP resolution via Tart Guest Agent when control socket URL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let ip = try await AgentResolver.ResolveIP(controlSocketURL) {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait a second
|
// wait a second
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ 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 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")
|
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: """
|
||||||
@@ -143,7 +139,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"), completion: .file())
|
""", valueName: "path[:options]"), completion: .file())
|
||||||
var disk: [String] = []
|
var disk: [String] = []
|
||||||
|
|
||||||
#if arch(arm64)
|
#if arch(arm64)
|
||||||
@@ -162,10 +158,6 @@ struct Run: AsyncParsableCommand {
|
|||||||
#endif
|
#endif
|
||||||
var rosettaTag: String?
|
var rosettaTag: String?
|
||||||
|
|
||||||
// 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: """
|
@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.
|
||||||
|
|
||||||
@@ -178,7 +170,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: "path"), completion: .directory)
|
""", valueName: "[name:]path[:options]"), completion: .directory)
|
||||||
var dir: [String] = []
|
var dir: [String] = []
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||||
@@ -320,9 +312,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")
|
||||||
@@ -348,6 +337,12 @@ struct Run: AsyncParsableCommand {
|
|||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
|
// Validate disk format support
|
||||||
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
if !vmConfig.diskFormat.isSupported {
|
||||||
|
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
||||||
|
}
|
||||||
|
|
||||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
try storageLock.lock()
|
try storageLock.lock()
|
||||||
// check if there is a running VM with the same MAC address
|
// check if there is a running VM with the same MAC address
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
enum DiskImageFormat: String, CaseIterable, Codable {
|
||||||
|
case raw = "raw"
|
||||||
|
case asif = "asif"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .raw:
|
||||||
|
return "RAW"
|
||||||
|
case .asif:
|
||||||
|
return "ASIF (Apple Sparse Image Format)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Check if the format is supported on the current system
|
||||||
|
var isSupported: Bool {
|
||||||
|
switch self {
|
||||||
|
case .raw:
|
||||||
|
return true
|
||||||
|
case .asif:
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DiskImageFormat: ExpressibleByArgument {
|
||||||
|
init?(argument: String) {
|
||||||
|
self.init(rawValue: argument.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
static var allValueStrings: [String] {
|
||||||
|
return allCases.map { $0.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import NIOPosix
|
||||||
|
import GRPC
|
||||||
|
import Cirruslabs_TartGuestAgent_Apple_Swift
|
||||||
|
import Cirruslabs_TartGuestAgent_Grpc_Swift
|
||||||
|
|
||||||
|
class AgentResolver {
|
||||||
|
static func ResolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
|
||||||
|
do {
|
||||||
|
return try await resolveIP(controlSocketURL)
|
||||||
|
} catch let error as GRPCConnectionPoolError {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveIP(_ controlSocketURL: URL) async throws -> IPv4Address? {
|
||||||
|
// 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(controlSocketURL.path()),
|
||||||
|
transportSecurity: .plaintext,
|
||||||
|
eventLoopGroup: group,
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
try! channel.close().wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke ResolveIP() gRPC method
|
||||||
|
let callOptions = CallOptions(timeLimit: .timeout(.seconds(1)))
|
||||||
|
let agentAsyncClient = AgentAsyncClient(channel: channel)
|
||||||
|
let resolveIPCall = agentAsyncClient.makeResolveIpCall(ResolveIPRequest(), callOptions: callOptions)
|
||||||
|
|
||||||
|
let response = try await resolveIPCall.response
|
||||||
|
|
||||||
|
return IPv4Address(response.ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
|||||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||||
|
|
||||||
|
// Manifest labels
|
||||||
|
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||||
|
|
||||||
// Layer annotations
|
// Layer annotations
|
||||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||||
|
|||||||
+35
-34
@@ -31,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)
|
||||||
@@ -83,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 {
|
||||||
@@ -96,6 +96,7 @@ 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 {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
|
|||||||
return name.replacingOccurrences(of: ":", with: "\\:")
|
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeMachines(_ arguments: [String]) -> [String] {
|
func completeMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||||
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||||
normalizeName(name)
|
normalizeName(name)
|
||||||
}) ?? []
|
}) ?? []
|
||||||
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
|
|||||||
return (localVMs + ociVMs)
|
return (localVMs + ociVMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
return localVMs.map { name, _ in normalizeName(name) }
|
return localVMs.map { name, _ in normalizeName(name) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
func completeRunningMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
return localVMs
|
return localVMs
|
||||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||||
|
|||||||
+12
-12
@@ -143,6 +143,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
vmDir: VMDirectory,
|
vmDir: VMDirectory,
|
||||||
ipswURL: URL,
|
ipswURL: URL,
|
||||||
diskSizeGB: UInt16,
|
diskSizeGB: UInt16,
|
||||||
|
diskFormat: DiskImageFormat = .raw,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
@@ -175,14 +176,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||||
|
|
||||||
// Create disk
|
// Create disk
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||||
|
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
// Create config
|
// Create config
|
||||||
config = VMConfig(
|
config = VMConfig(
|
||||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
memorySizeMin: requirements.minimumSupportedMemorySize,
|
||||||
|
diskFormat: diskFormat
|
||||||
)
|
)
|
||||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||||
@@ -224,15 +226,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM {
|
||||||
// Create NVRAM
|
// Create NVRAM
|
||||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||||
|
|
||||||
// Create disk
|
// Create disk
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||||
|
|
||||||
// Create config
|
// Create config
|
||||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat)
|
||||||
try config.save(toURL: vmDir.configURL)
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
return try VM(vmDir: vmDir)
|
return try VM(vmDir: vmDir)
|
||||||
@@ -412,15 +414,13 @@ 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
|
// Socket device
|
||||||
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
configuration.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ enum CodingKeys: String, CodingKey {
|
|||||||
case macAddress
|
case macAddress
|
||||||
case display
|
case display
|
||||||
case displayRefit
|
case displayRefit
|
||||||
|
case diskFormat
|
||||||
|
|
||||||
// macOS-specific keys
|
// macOS-specific keys
|
||||||
case ecid
|
case ecid
|
||||||
@@ -54,12 +55,14 @@ struct VMConfig: Codable {
|
|||||||
var macAddress: VZMACAddress
|
var macAddress: VZMACAddress
|
||||||
var display: VMDisplayConfig = VMDisplayConfig()
|
var display: VMDisplayConfig = VMDisplayConfig()
|
||||||
var displayRefit: Bool?
|
var displayRefit: Bool?
|
||||||
|
var diskFormat: DiskImageFormat = .raw
|
||||||
|
|
||||||
init(
|
init(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
cpuCountMin: Int,
|
cpuCountMin: Int,
|
||||||
memorySizeMin: UInt64,
|
memorySizeMin: UInt64,
|
||||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
|
||||||
|
diskFormat: DiskImageFormat = .raw
|
||||||
) {
|
) {
|
||||||
self.os = platform.os()
|
self.os = platform.os()
|
||||||
self.arch = CurrentArchitecture()
|
self.arch = CurrentArchitecture()
|
||||||
@@ -67,6 +70,7 @@ struct VMConfig: Codable {
|
|||||||
self.macAddress = macAddress
|
self.macAddress = macAddress
|
||||||
self.cpuCountMin = cpuCountMin
|
self.cpuCountMin = cpuCountMin
|
||||||
self.memorySizeMin = memorySizeMin
|
self.memorySizeMin = memorySizeMin
|
||||||
|
self.diskFormat = diskFormat
|
||||||
cpuCount = cpuCountMin
|
cpuCount = cpuCountMin
|
||||||
memorySize = memorySizeMin
|
memorySize = memorySizeMin
|
||||||
}
|
}
|
||||||
@@ -124,6 +128,8 @@ struct VMConfig: Codable {
|
|||||||
|
|
||||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||||
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
||||||
|
let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw"
|
||||||
|
diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -142,6 +148,7 @@ struct VMConfig: Codable {
|
|||||||
if let displayRefit = displayRefit {
|
if let displayRefit = displayRefit {
|
||||||
try container.encode(displayRefit, forKey: .displayRefit)
|
try container.encode(displayRefit, forKey: .displayRefit)
|
||||||
}
|
}
|
||||||
|
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
let config = try VMConfig(fromURL: configURL)
|
let config = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
// Add disk format label automatically
|
||||||
|
var labels = labels
|
||||||
|
labels[diskFormatLabel] = config.diskFormat.rawValue
|
||||||
let configJSON = try JSONEncoder().encode(config)
|
let configJSON = try JSONEncoder().encode(config)
|
||||||
defaultLogger.appendNewLine("pushing config...")
|
defaultLogger.appendNewLine("pushing config...")
|
||||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
// MARK: - Disk Image Info Structures
|
||||||
|
struct DiskImageInfo: Codable {
|
||||||
|
let sizeInfo: SizeInfo?
|
||||||
|
let size: UInt64?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case sizeInfo = "Size Info"
|
||||||
|
case size = "Size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SizeInfo: Codable {
|
||||||
|
let totalBytes: UInt64?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalBytes = "Total Bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
enum State: String {
|
enum State: String {
|
||||||
case Running = "running"
|
case Running = "running"
|
||||||
@@ -142,14 +161,34 @@ struct VMDirectory: Prunable {
|
|||||||
try vmConfig.save(toURL: configURL)
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
|
||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if diskExists {
|
||||||
|
// Existing disk - resize it
|
||||||
|
try resizeExistingDisk(sizeGB)
|
||||||
|
} else {
|
||||||
|
// New disk - create it with the specified format
|
||||||
|
try createDisk(sizeGB: sizeGB, format: format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeExistingDisk(_ sizeGB: UInt16) throws {
|
||||||
|
// Check if this is an ASIF disk by reading the VM config
|
||||||
|
let vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
if vmConfig.diskFormat == .asif {
|
||||||
|
try resizeASIFDisk(sizeGB)
|
||||||
|
} else {
|
||||||
|
try resizeRawDisk(sizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeRawDisk(_ sizeGB: UInt16) throws {
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
|
||||||
if desiredDiskFileLength < currentDiskFileLength {
|
if desiredDiskFileLength < currentDiskFileLength {
|
||||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||||
@@ -161,6 +200,157 @@ struct VMDirectory: Prunable {
|
|||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
|
||||||
|
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, get current disk image info to check current size
|
||||||
|
let infoProcess = Process()
|
||||||
|
infoProcess.executableURL = diskutilURL
|
||||||
|
infoProcess.arguments = ["image", "info", "--plist", diskURL.path]
|
||||||
|
|
||||||
|
let infoPipe = Pipe()
|
||||||
|
infoProcess.standardOutput = infoPipe
|
||||||
|
infoProcess.standardError = infoPipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try infoProcess.run()
|
||||||
|
infoProcess.waitUntilExit()
|
||||||
|
|
||||||
|
let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
|
if infoProcess.terminationStatus != 0 {
|
||||||
|
let output = String(data: infoData, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the plist using PropertyListDecoder
|
||||||
|
do {
|
||||||
|
let diskImageInfo = try PropertyListDecoder().decode(DiskImageInfo.self, from: infoData)
|
||||||
|
|
||||||
|
// Extract current size from the decoded structure
|
||||||
|
var currentSizeBytes: UInt64?
|
||||||
|
|
||||||
|
// Try to get size from Size Info -> Total Bytes first
|
||||||
|
if let totalBytes = diskImageInfo.sizeInfo?.totalBytes {
|
||||||
|
currentSizeBytes = totalBytes
|
||||||
|
} else if let size = diskImageInfo.size {
|
||||||
|
// Fallback to top-level Size field
|
||||||
|
currentSizeBytes = size
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let currentSizeBytes = currentSizeBytes else {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Could not find size information in disk image info")
|
||||||
|
}
|
||||||
|
|
||||||
|
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
|
||||||
|
if desiredSizeBytes < currentSizeBytes {
|
||||||
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
||||||
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
||||||
|
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||||
|
"than the current disk size of \(currentLengthHuman)")
|
||||||
|
} else if desiredSizeBytes > currentSizeBytes {
|
||||||
|
// Resize the ASIF disk image using diskutil
|
||||||
|
try performASIFResize(sizeGB)
|
||||||
|
}
|
||||||
|
// If sizes are equal, no action needed
|
||||||
|
} catch let error as RuntimeError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output"
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performASIFResize(_ sizeGB: UInt16) throws {
|
||||||
|
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = diskutilURL
|
||||||
|
process.arguments = [
|
||||||
|
"image", "resize",
|
||||||
|
"--size", "\(sizeGB)G",
|
||||||
|
diskURL.path
|
||||||
|
]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
|
if process.terminationStatus != 0 {
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws {
|
||||||
|
switch format {
|
||||||
|
case .raw:
|
||||||
|
try createRawDisk(sizeGB: sizeGB)
|
||||||
|
case .asif:
|
||||||
|
try createASIFDisk(sizeGB: sizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRawDisk(sizeGB: UInt16) throws {
|
||||||
|
// Create traditional raw disk image
|
||||||
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
|
|
||||||
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||||
|
try diskFileHandle.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createASIFDisk(sizeGB: UInt16) throws {
|
||||||
|
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||||
|
throw RuntimeError.FailedToCreateDisk("diskutil not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = diskutilURL
|
||||||
|
process.arguments = [
|
||||||
|
"image", "create", "blank",
|
||||||
|
"--format", "ASIF",
|
||||||
|
"--size", "\(sizeGB)G",
|
||||||
|
"--volumeName", "Tart",
|
||||||
|
diskURL.path
|
||||||
|
]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if process.terminationStatus != 0 {
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(output)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.FailedToCreateDisk("Failed to execute diskutil: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
let lock = try lock()
|
let lock = try lock()
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ enum RuntimeError : Error {
|
|||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||||
case InvalidDiskSize(_ message: String)
|
case InvalidDiskSize(_ message: String)
|
||||||
|
case FailedToCreateDisk(_ message: String)
|
||||||
|
case FailedToResizeDisk(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
case PIDLockMissing(_ message: String)
|
case PIDLockMissing(_ message: String)
|
||||||
@@ -109,6 +111,10 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "failed to open block device \(path): \(explanation)"
|
return "failed to open block device \(path): \(explanation)"
|
||||||
case .InvalidDiskSize(let message):
|
case .InvalidDiskSize(let message):
|
||||||
return message
|
return message
|
||||||
|
case .FailedToCreateDisk(let message):
|
||||||
|
return message
|
||||||
|
case .FailedToResizeDisk(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
|
||||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||||
|
|
||||||
try vmDir.validate(userFriendlyName: name.description)
|
try vmDir.validate(userFriendlyName: name.description)
|
||||||
|
|
||||||
try vmDir.baseURL.updateAccessDate()
|
try vmDir.baseURL.updateAccessDate(accessDate)
|
||||||
|
|
||||||
return vmDir
|
return vmDir
|
||||||
}
|
}
|
||||||
@@ -180,6 +180,10 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||||
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||||
|
|
||||||
|
// Open an existing VM directory corresponding to this name, if any,
|
||||||
|
// marking it as outdated to speed up the garbage collection process
|
||||||
|
_ = try? open(name, Date(timeIntervalSince1970: 0))
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
try tmpVMDirLock.lock()
|
try tmpVMDirLock.lock()
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DiskImageFormatTests: XCTestCase {
|
||||||
|
func testRawFormatIsAlwaysSupported() throws {
|
||||||
|
XCTAssertTrue(DiskImageFormat.raw.isSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testASIFFormatSupport() throws {
|
||||||
|
// ASIF should be supported on macOS 15+
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||||
|
} else {
|
||||||
|
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatFromString() throws {
|
||||||
|
XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw)
|
||||||
|
XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif)
|
||||||
|
XCTAssertNil(DiskImageFormat(rawValue: "invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaseInsensitivity() throws {
|
||||||
|
XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive
|
||||||
|
XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAllValueStrings() throws {
|
||||||
|
let allValues = DiskImageFormat.allValueStrings
|
||||||
|
XCTAssertTrue(allValues.contains("raw"))
|
||||||
|
XCTAssertTrue(allValues.contains("asif"))
|
||||||
|
XCTAssertEqual(allValues.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVMConfigDiskFormatSerialization() throws {
|
||||||
|
// Test that VMConfig properly serializes and deserializes disk format
|
||||||
|
let config = VMConfig(
|
||||||
|
platform: Linux(),
|
||||||
|
cpuCountMin: 2,
|
||||||
|
memorySizeMin: 1024 * 1024 * 1024,
|
||||||
|
diskFormat: .asif
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.diskFormat, .asif)
|
||||||
|
|
||||||
|
// Test JSON encoding/decoding
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(config)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let decodedConfig = try decoder.decode(VMConfig.self, from: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(decodedConfig.diskFormat, .asif)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVMConfigDefaultDiskFormat() throws {
|
||||||
|
// Test that VMConfig defaults to raw format
|
||||||
|
let config = VMConfig(
|
||||||
|
platform: Linux(),
|
||||||
|
cpuCountMin: 2,
|
||||||
|
memorySizeMin: 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.diskFormat, .raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,5 @@
|
|||||||
"MD033": false # Inline HTML
|
"MD033": false # Inline HTML
|
||||||
"MD041": false # First line in file should be a top level heading
|
"MD041": false # First line in file should be a top level heading
|
||||||
"MD045": false # OK not to have a description for an image
|
"MD045": false # OK not to have a description for an image
|
||||||
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
||||||
|
"MD059": false # It's OK to have "here" links
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
draft: false
|
||||||
|
date: 2025-06-01
|
||||||
|
search:
|
||||||
|
exclude: true
|
||||||
|
authors:
|
||||||
|
- edigaryev
|
||||||
|
categories:
|
||||||
|
- announcement
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bridging the gaps with the Tart Guest Agent
|
||||||
|
|
||||||
|
We're introducing a new improvement for the Tart usability experience: a [Tart Guest Agent](https://github.com/cirruslabs/tart-guest-agent).
|
||||||
|
|
||||||
|
This agent provides automatic disk resizing, seamless clipboard sharing for macOS guests (a [long-awaited](https://github.com/cirruslabs/tart/issues/14) feature), and the ability to run commands, without SSH and networking, using the new `tart exec` command.
|
||||||
|
|
||||||
|
As of recently, we include this agent in all non-vanilla Cirrus Labs images, so you likely won't need to do anything to benefit from these usability improvements.
|
||||||
|
|
||||||
|
Read on to learn why we chose to implement the agent from scratch in Golang, and which features we plan to add next.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
## Existing solutions
|
||||||
|
|
||||||
|
Tart uses the Virtualization.Framework, and the latter implemented a SPICE client some time ago, however, one piece was missing: the agent that runs inside the guest.
|
||||||
|
|
||||||
|
The original [SPICE `vdagent` implementation](https://gitlab.freedesktop.org/spice/linux/vd_agent) only supports Linux. While [a fork](https://github.com/utmapp/vd_agent) from the UTM project adds macOS support, the long-term viability of maintaining this fork without upstreaming changes is uncertain.
|
||||||
|
|
||||||
|
Moreover, if we were to add some extra functionality (as we did), there would be more than one agent binary to ship and install, which complicates maintenance and makes it harder to explain to users why we need a bunch of agent binaries.
|
||||||
|
|
||||||
|
In the end, we decided to go with our own solution, one that would easily accomodate future ideas.
|
||||||
|
|
||||||
|
## Rolling our own agent
|
||||||
|
|
||||||
|
After carefully inspecting the [`vdagent` protocol](https://www.spice-space.org/agent-protocol.html) we've realized that the clipboard sharing is actually a small subset of the whole protocol, making it relatively simple to implement.
|
||||||
|
|
||||||
|
Thanks to Golang, we were able to implement the protocol much faster than we could have with a lower-level language like C (with all due respect), which requires manual memory management and complex event loops.
|
||||||
|
|
||||||
|
As for the command execution via `tart exec`, we've decided to go with gRPC with a rather simple protocol:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For each `tart exec` invocation a new gRPC `Exec` bidirectional stream is established with the agent running inside a VM. After the gRPC stream is established, `tart exec` sends a command to execute to the guest and streams the I/O. Once the command terminates, `tart exec` collects the process exit code and quits with exactly that exit code.
|
||||||
|
|
||||||
|
Using gRPC simplifies `tart exec` implementation because of code generation and forms a nice bridge between the host and the guest which allows us to easily expand the protocol later down the road when we decide to introduce new features.
|
||||||
|
|
||||||
|
Thanks to [gRPC Swift](https://github.com/grpc/grpc-swift), which is built on top of [SwiftNIO](https://github.com/apple/swift-nio), we get [`async/await`](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) support for free, further simplifying the `tart exec` logic.
|
||||||
|
|
||||||
|
As for the Tart Guest Agent, the final result is a Golang binary that [can be customized](https://github.com/cirruslabs/tart-guest-agent?tab=readme-ov-file#guest-agent-for-tart-vms) depending on the execution context:
|
||||||
|
|
||||||
|
* launchd global daemon — runs as a privileged user (`root`), has no clipboard access
|
||||||
|
* `--resize-disk` — resizes the disk when there's a free space at the end of a disk (assuming that one previously ran `tart set --disk-size`)
|
||||||
|
* launchd global agent — runs as a normal user (`admin`), has clipboard access
|
||||||
|
* `--run-vdagent` — clipboard sharing
|
||||||
|
* `--run-rpc` — `tart exec` and new functionality in the future
|
||||||
|
|
||||||
|
We’ve also introduced `--run-daemon` (which implies `--resize-disk`) and `--run-agent` (which implies both `--run-vdagent` and `--run-rpc`) to help run the most appropriate functionality based on the given context.
|
||||||
|
|
||||||
|
## Future plans
|
||||||
|
|
||||||
|
First, we'd like to thank our paid clients, without whom this feature wouldn't have been possible.
|
||||||
|
|
||||||
|
[Become one now](../../licensing.md) and enjoy higher allowances for Tart VMs and Orchard workers—while helping ensure that our roadmap aligns with your company's needs.
|
||||||
|
|
||||||
|
In the near future we plan to implement:
|
||||||
|
|
||||||
|
* Linux support — to provide seamless experience for Linux guests too
|
||||||
|
* a new `tart ip` resolver — to provide a more robust IP retrieval facility for Linux guests, which often struggle to populate the host's ARP table with their network activity
|
||||||
|
* `tart cp` command — to copy files from/to guest VMs
|
||||||
|
|
||||||
|
Stay tuned, and feel free to send us feedback on [GitHub](https://github.com/cirruslabs/tart) and [Twitter](https://x.com/cirrus_labs)!
|
||||||
@@ -46,6 +46,7 @@ plugins:
|
|||||||
match_path: blog/posts/.*
|
match_path: blog/posts/.*
|
||||||
date_from_meta:
|
date_from_meta:
|
||||||
as_creation: date
|
as_creation: date
|
||||||
|
abstract_chars_count: -1
|
||||||
- social:
|
- social:
|
||||||
cards_layout_dir: docs/layouts
|
cards_layout_dir: docs/layouts
|
||||||
cards_layout: custom
|
cards_layout: custom
|
||||||
|
|||||||
Reference in New Issue
Block a user