mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-02 04:01:12 +02:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a65ab5a712 | ||
|
|
3f7b6d2a61 | ||
|
|
0debec1266 | ||
|
|
294c5fc5e5 | ||
|
|
99777b6740 | ||
|
|
a2972aa4d9 | ||
|
|
3a6c5fb81d | ||
|
|
5793935317 | ||
|
|
8dc8b644b2 | ||
|
|
b625c04131 | ||
|
|
a0c03dcce6 |
+4
-2
@@ -83,8 +83,9 @@ task:
|
||||
- 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
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install go
|
||||
- brew install mitchellh/gon/gon
|
||||
- brew install --cask goreleaser/tap/goreleaser-pro
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
@@ -121,8 +122,9 @@ task:
|
||||
- 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
|
||||
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 --cask goreleaser/tap/goreleaser-pro
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
||||
# model: "claude-opus-4-20250514"
|
||||
|
||||
# Direct prompt for automated review (no @claude mention needed)
|
||||
direct_prompt: |
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Be constructive and helpful in your feedback.
|
||||
|
||||
# Optional: Use sticky comments to make Claude reuse the same comment on subsequent pushes to the same PR
|
||||
# use_sticky_comment: true
|
||||
|
||||
# Optional: Customize review based on file types
|
||||
# direct_prompt: |
|
||||
# Review this PR focusing on:
|
||||
# - For TypeScript files: Type safety and proper interface usage
|
||||
# - For API endpoints: Security, input validation, and error handling
|
||||
# - For React components: Performance, accessibility, and best practices
|
||||
# - For tests: Coverage, edge cases, and test quality
|
||||
|
||||
# Optional: Different prompts for different authors
|
||||
# direct_prompt: |
|
||||
# ${{ github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' &&
|
||||
# 'Welcome! Please review this PR from a first-time contributor. Be encouraging and provide detailed explanations for any suggestions.' ||
|
||||
# 'Please provide a thorough code review focusing on our coding standards and best practices.' }}
|
||||
|
||||
# Optional: Add specific tools for running tests or linting
|
||||
# allowed_tools: "Bash(npm run test),Bash(npm run lint),Bash(npm run typecheck)"
|
||||
|
||||
# Optional: Skip review for certain conditions
|
||||
# if: |
|
||||
# !contains(github.event.pull_request.title, '[skip-review]') &&
|
||||
# !contains(github.event.pull_request.title, '[WIP]')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Specify model (defaults to Claude Sonnet 4, uncomment for Claude Opus 4)
|
||||
# model: "claude-opus-4-20250514"
|
||||
|
||||
# Optional: Customize the trigger phrase (default: @claude)
|
||||
# trigger_phrase: "/claude"
|
||||
|
||||
# Optional: Trigger when specific user is assigned to an issue
|
||||
# assignee_trigger: "claude-bot"
|
||||
|
||||
# Optional: Allow Claude to run specific commands
|
||||
# allowed_tools: "Bash(npm install),Bash(npm run build),Bash(npm run test:*),Bash(npm run lint:*)"
|
||||
|
||||
# Optional: Add custom instructions for Claude to customize its behavior for your project
|
||||
# custom_instructions: |
|
||||
# Follow our coding standards
|
||||
# Ensure all new code has tests
|
||||
# Use TypeScript for new files
|
||||
|
||||
# Optional: Custom environment variables for Claude
|
||||
# claude_env: |
|
||||
# NODE_ENV: test
|
||||
|
||||
@@ -8,6 +8,9 @@ tart.xcodeproj/
|
||||
# AppCode
|
||||
.idea/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Swift
|
||||
.build/
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Table of Contents
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||
```
|
||||
|
||||
+9
-9
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "90af6efa7ed1bbb0cd6985eb109c1e265a223a697b7fbaae2453206a892180e7",
|
||||
"originHash" : "668bad809d4882f75f097e66a12a6dbc8e61ec998f1800a7e09439c854fadda1",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
@@ -13,19 +13,19 @@
|
||||
{
|
||||
"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",
|
||||
"location" : "https://buf.build/gen/swift/git/1.28.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_apple_swift.git",
|
||||
"state" : {
|
||||
"revision" : "3e13bec2dd36788e80a2e5a2022d44d4a1f373cf",
|
||||
"version" : "1.28.2-00000000000000-dfeb75ad2b39.1"
|
||||
"revision" : "ccfae5de1917cdb0d7c5000008fa5ed0bad032bf",
|
||||
"version" : "1.28.2-00000000000000-17d7dedafb88.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",
|
||||
"location" : "https://buf.build/gen/swift/git/1.24.2-00000000000000-17d7dedafb88.1/cirruslabs_tart-guest-agent_grpc_swift.git",
|
||||
"state" : {
|
||||
"branch" : "1.24.2-00000000000000-dfeb75ad2b39.1",
|
||||
"revision" : "5b6ff43b580fe435f0a174e137e2b197759a7170"
|
||||
"branch" : "1.24.2-00000000000000-17d7dedafb88.1",
|
||||
"revision" : "b8421f137325fe8de737ff5b61238f6f2131b2a8"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -78,8 +78,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
"revision" : "309a47b2b1d9b5e991f36961c983ecec72275be3",
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
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/apple/swift-algorithms", from: "1.2.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
@@ -25,7 +25,7 @@ let package = Package(
|
||||
.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"),
|
||||
.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: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
|
||||
@@ -66,8 +66,8 @@ Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura)
|
||||
|
||||
```bash
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest sequoia-base
|
||||
tart run sequoia-base
|
||||
tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-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)
|
||||
|
||||
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
|
||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||
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 {
|
||||
if fromIPSW == nil && !linux {
|
||||
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!")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Validate disk format support
|
||||
if !diskFormat.isSupported {
|
||||
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -15,15 +15,15 @@ struct Exec: AsyncParsableCommand {
|
||||
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(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Command to execute")
|
||||
var command: [String]
|
||||
|
||||
@@ -79,8 +79,7 @@ struct Exec: AsyncParsableCommand {
|
||||
|
||||
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)
|
||||
let execCall = agentAsyncClient.makeExecCall()
|
||||
|
||||
try await execCall.requestStream.send(.with {
|
||||
$0.type = .command(.with {
|
||||
|
||||
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let DiskFormat: String
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import SystemConfiguration
|
||||
import Sentry
|
||||
|
||||
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 {
|
||||
@@ -19,13 +19,11 @@ struct IP: AsyncParsableCommand {
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
var wait: UInt16 = 0
|
||||
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
|
||||
@Option(help: ArgumentHelp("Strategy for resolving IP address",
|
||||
discussion: """
|
||||
By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
|
||||
This method is fast and the most reliable but only returns local IP adresses.\n
|
||||
Alternatively, Tart can call external `arp` executable and parse it's output.\n
|
||||
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`.
|
||||
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
|
||||
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
|
||||
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.
|
||||
"""))
|
||||
var resolver: IPResolutionStrategy = .dhcp
|
||||
|
||||
@@ -34,14 +32,16 @@ struct IP: AsyncParsableCommand {
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
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"
|
||||
|
||||
if try !vmDir.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)"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ struct IP: AsyncParsableCommand {
|
||||
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)!
|
||||
|
||||
repeat {
|
||||
@@ -64,6 +64,14 @@ struct IP: AsyncParsableCommand {
|
||||
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||
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
|
||||
|
||||
@@ -116,10 +116,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
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("""
|
||||
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: """
|
||||
@@ -143,7 +139,7 @@ struct Run: AsyncParsableCommand {
|
||||
To work around this pass TART_HOME explicitly:
|
||||
|
||||
sudo TART_HOME="$HOME/.tart" tart run sequoia --disk=/dev/disk0
|
||||
""", valueName: "path"), completion: .file())
|
||||
""", valueName: "path[:options]"), completion: .file())
|
||||
var disk: [String] = []
|
||||
|
||||
#if arch(arm64)
|
||||
@@ -162,10 +158,6 @@ struct Run: AsyncParsableCommand {
|
||||
#endif
|
||||
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: """
|
||||
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.
|
||||
|
||||
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] = []
|
||||
|
||||
@Flag(help: ArgumentHelp("Enable nested virtualization if possible"))
|
||||
@@ -345,6 +337,12 @@ struct Run: AsyncParsableCommand {
|
||||
let localStorage = VMStorageLocal()
|
||||
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)
|
||||
try storageLock.lock()
|
||||
// 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 uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||
|
||||
// Manifest labels
|
||||
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||
|
||||
// Layer annotations
|
||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||
|
||||
@@ -5,7 +5,7 @@ fileprivate func normalizeName(_ name: String) -> String {
|
||||
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
|
||||
normalizeName(name)
|
||||
}) ?? []
|
||||
@@ -15,12 +15,12 @@ func completeMachines(_ arguments: [String]) -> [String] {
|
||||
return (localVMs + ociVMs)
|
||||
}
|
||||
|
||||
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||
func completeLocalMachines(_ arguments: [String], _ argumentIdx: Int, _ argumentPrefix: String) -> [String] {
|
||||
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||
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()) ?? []
|
||||
return localVMs
|
||||
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||
|
||||
@@ -143,6 +143,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
diskFormat: DiskImageFormat = .raw,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
@@ -175,14 +176,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize,
|
||||
diskFormat: diskFormat
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
@@ -224,15 +226,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
#endif
|
||||
|
||||
@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
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||
|
||||
// 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)
|
||||
|
||||
return try VM(vmDir: vmDir)
|
||||
|
||||
@@ -25,6 +25,7 @@ enum CodingKeys: String, CodingKey {
|
||||
case macAddress
|
||||
case display
|
||||
case displayRefit
|
||||
case diskFormat
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
@@ -54,12 +55,14 @@ struct VMConfig: Codable {
|
||||
var macAddress: VZMACAddress
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
var displayRefit: Bool?
|
||||
var diskFormat: DiskImageFormat = .raw
|
||||
|
||||
init(
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
|
||||
diskFormat: DiskImageFormat = .raw
|
||||
) {
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
@@ -67,6 +70,7 @@ struct VMConfig: Codable {
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
self.diskFormat = diskFormat
|
||||
cpuCount = cpuCountMin
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
@@ -124,6 +128,8 @@ struct VMConfig: Codable {
|
||||
|
||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||
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 {
|
||||
@@ -142,6 +148,7 @@ struct VMConfig: Codable {
|
||||
if let displayRefit = displayRefit {
|
||||
try container.encode(displayRefit, forKey: .displayRefit)
|
||||
}
|
||||
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
|
||||
}
|
||||
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
|
||||
@@ -92,6 +92,10 @@ extension VMDirectory {
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
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)
|
||||
defaultLogger.appendNewLine("pushing config...")
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
|
||||
@@ -2,6 +2,25 @@ import Foundation
|
||||
import Virtualization
|
||||
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 {
|
||||
enum State: String {
|
||||
case Running = "running"
|
||||
@@ -142,14 +161,34 @@ struct VMDirectory: Prunable {
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
|
||||
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
|
||||
|
||||
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 currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||
|
||||
if desiredDiskFileLength < currentDiskFileLength {
|
||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||
@@ -161,6 +200,157 @@ struct VMDirectory: Prunable {
|
||||
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 {
|
||||
let lock = try lock()
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ enum RuntimeError : Error {
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToCreateDisk(_ message: String)
|
||||
case FailedToResizeDisk(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case PIDLockMissing(_ message: String)
|
||||
@@ -109,6 +111,10 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "failed to open block device \(path): \(explanation)"
|
||||
case .InvalidDiskSize(let message):
|
||||
return message
|
||||
case .FailedToCreateDisk(let message):
|
||||
return message
|
||||
case .FailedToResizeDisk(let message):
|
||||
return message
|
||||
case .FailedToUpdateAccessDate(let message):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
|
||||
@@ -27,12 +27,12 @@ class VMStorageOCI: PrunableStorage {
|
||||
return digest
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
func open(_ name: RemoteName, _ accessDate: Date = Date()) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate(userFriendlyName: name.description)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
try vmDir.baseURL.updateAccessDate(accessDate)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
@@ -180,6 +180,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||
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
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
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
|
||||
"MD041": false # First line in file should be a top level heading
|
||||
"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/.*
|
||||
date_from_meta:
|
||||
as_creation: date
|
||||
abstract_chars_count: -1
|
||||
- social:
|
||||
cards_layout_dir: docs/layouts
|
||||
cards_layout: custom
|
||||
|
||||
Reference in New Issue
Block a user