Compare commits

..
20 Commits
Author SHA1 Message Date
Fedor Korotkov 704811e671 Introduce TART_NO_AUTO_PRUNE (#565)
* Introduce `TART_NO_AUTO_PRUNE`

Similar to `HOMEBREW_NO_AUTO_UPDATE`

* Self review
2023-07-25 16:01:53 +00:00
Fedor Korotkov d4fcecd47c [skip ci] Downgrade Lottie Player (#563)
Seems in 2.0.0 release they broke looping. Right now animation on https://tart.run/ is always looping even without `loop` property. I was able to disable it, so I just downgraded to the last known working version.
2023-07-20 19:46:58 +04:00
Nikolay Edigaryev 33ca96e1a0 Retrieve VM's IP for use in VNC after the VM is started #2 (#562) 2023-07-17 12:51:43 +04:00
Fedor Korotkov 4ce06279ff [skip ci] Clarify default runner group assigment (#559) 2023-07-14 07:50:31 -04:00
Fedor Korotkov fa97adfc9e Highlight Cirrus Runners in README.md (#558)
* Mention Cirrus Runners in the README.md

* Increase font size

* typo

* Fixed branch
2023-07-13 18:09:19 +00:00
Nikolay Edigaryev 6c377029d6 tart prune: allow pruning local VMs with --entries=vms (#557) 2023-07-13 22:05:28 +04:00
Fedor Korotkov 93a1b70ecb Don't delete an initiator of pruning (#556)
* Don't delete an initiator of pruning

Sometimes people have an image that is greater than half of the disk itself. In that case such image will be pulled and prunned right away.

This change makes sure that an image that is being cloned from is not pruned right away.

* Resolve symbolic links
2023-07-13 13:59:23 +04:00
Fedor Korotkov cf9a3a9221 Allow mounting a single directory without a name (#555)
* Allow mounting a single directory without a name

To utilize `VZSingleDirectoryShare` which seems more stable than `VZMultipleDirectoryShare`.

We've been having reports from users that mounted directories occasionally return "no such file" errors when building large projects. I took a stab at reproducing the issue by running https://github.com/devMEremenko/XcodeBenchmark in a mounted directory:

```bash
tart run --dir=workdir-test:~/workspace-temp/XcodeBenchmark ventura-xcode
```

And I was able to reproduce the "no such file" error on the first try! After looking into the issue I decided to try `VZSingleDirectoryShare` as this PR changes and to my pleasant surprise it all worked like a charm the next run. So it seems there is a bug in `VZMultipleDirectoryShare` integration with virtiofs. Since in most cases users only mount a single directory it makes sense to allow doing it wihtout providing a `name`.

So now it will be possible to run the following command:

```bash
tart run --dir=~/workspace-temp/XcodeBenchmark ventura-xcode
```

Which will make `~/workspace-temp/XcodeBenchmark` available under `/Volumes/My Shared Files/` without any intermediate directories.

* Reformat

* Updated description
2023-07-12 19:08:52 +00:00
Stanisław Chmiela ad566faa23 Add OCI annotation with upload time (#551) 2023-07-11 19:02:38 -04:00
Fedor Korotkov 1afaa7ec7a Reclaim disk space only in the very end of tart clone (#553)
* Reclaim disk space only in the very end of `tart clone`

Please check comments in code for reasoning.

* Phrasing

* Removed hidden argument
2023-07-11 15:49:43 +00:00
Fedor Korotkov 826e508646 Upload debug files to both Sentry projects (#552)
* Upload debug files to both Sentry projects

Apparently it's not possible to share debug files cross projects. So let's upload to both of them. To one we use in production and one we use for testing.

* Use environment variable
2023-07-11 17:49:04 +04:00
Stanisław ChmielaandFedor Korotkov 8653ca4115 Fix prune --older-than deleting all cache (#549)
* Fix `prune --older-than`

* Update Sources/tart/Commands/Prune.swift

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-11 16:10:49 +04:00
Nikolay Edigaryev 63b74f407b Version console device (#546) 2023-07-10 15:23:50 +00:00
Nikolay Edigaryev 3a2cba6929 Introduce TART_REGISTRY_HOSTNAME (#545)
* Introduce TART_REGISTRY_HOST

* TART_REGISTRY_HOST → TART_REGISTRY_HOSTNAME
2023-07-10 18:19:21 +04:00
Stanisław Chmiela 7592b86663 Add host to Registry taking port into consideration (#544) 2023-07-07 18:32:41 -04:00
Fedor Korotkov e8dbb86fc0 Support stopping of suspended VMs (#541) 2023-07-07 13:38:34 +04:00
Fedor Korotkov d2ed4ef801 Resume suspended VMs without explicit --suspendable flag (#540) 2023-07-07 11:38:31 +04:00
Nikolay EdigaryevandFedor Korotkov 2014de7dac Suspend/resume support (#527)
* Suspend/resume support

* Use RuntimeError.SuspendFailed for consistency's sake

* Add a comment about "Running" field deprecation

* Use compute credits

* Use Mac-specific input devices and remove --no-{audio,entropy}

* Suspend the VM when closing window and running with --suspendable

* Snapshotting Improvements (#539)

* Don't use static field for arguments

It throws a runtime error

* Fixed suspendability

* Lazy generation of new MAC addresses

To support cloning on suspended VMs

* Refactored

* formatted

* Configurable signal for window closing

* reformatted

* Don't generate MAC only for suspended VMs

* Removed misleading comment

* Reverted

* Lock while a suspendable VM is starting

* Lock on TART_HOME

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-06 18:04:39 +00:00
Nikolay Edigaryev 1f23b24920 PIDLock: check open(2) error (#538)
* PIDLock: check open(2) error

* Bump Sentry to 8.8.0
2023-07-04 15:22:25 +00:00
Fedor Korotkov 6ce4a06089 Document how to remount directories on macOS guests (#537) 2023-07-03 20:21:08 +04:00
31 changed files with 533 additions and 116 deletions
+10 -2
View File
@@ -6,6 +6,7 @@ env:
task: task:
name: Test on Ventura name: Test on Ventura
alias: test alias: test
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
persistent_worker: persistent_worker:
labels: labels:
name: dev-mini name: dev-mini
@@ -30,6 +31,7 @@ task:
task: task:
name: Lint name: Lint
alias: lint alias: lint
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
lint_script: lint_script:
@@ -40,9 +42,10 @@ task:
format: swiftformat format: swiftformat
task: task:
only_if: $CIRRUS_TAG == ''
name: Build name: Build
alias: build alias: build
only_if: $CIRRUS_TAG == '' use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
build_script: swift build --product tart build_script: swift build --product tart
@@ -51,11 +54,12 @@ task:
path: .build/debug/tart path: .build/debug/tart
task: task:
name: Release (Dry Run)
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin') only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
name: Release (Dry Run)
depends_on: depends_on:
- lint - lint
- build - build
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
env: env:
@@ -91,6 +95,7 @@ task:
- lint - lint
- test - test
- build - build
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
env: env:
@@ -123,9 +128,11 @@ task:
# Generate and upload symbols # Generate and upload symbols
- dsymutil tart - dsymutil tart
- sentry-cli debug-files upload tart.dSYM/ - sentry-cli debug-files upload tart.dSYM/
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
# Bundle and upload sources # Bundle and upload sources
- sentry-cli debug-files bundle-sources tart.dSYM - sentry-cli debug-files bundle-sources tart.dSYM
- sentry-cli debug-files upload tart.src.zip - sentry-cli debug-files upload tart.src.zip
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
create_sentry_release_script: create_sentry_release_script:
- export SENTRY_RELEASE="tart@$CIRRUS_TAG" - export SENTRY_RELEASE="tart@$CIRRUS_TAG"
- sentry-cli releases new $SENTRY_RELEASE - sentry-cli releases new $SENTRY_RELEASE
@@ -135,6 +142,7 @@ task:
task: task:
name: Deploy Documentation name: Deploy Documentation
only_if: $CIRRUS_BRANCH == 'main' only_if: $CIRRUS_BRANCH == 'main'
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
container: container:
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!] registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
+2 -2
View File
@@ -23,8 +23,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/getsentry/sentry-cocoa", "location" : "https://github.com/getsentry/sentry-cocoa",
"state" : { "state" : {
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852", "revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
"version" : "8.3.3" "version" : "8.8.0"
} }
}, },
{ {
+1 -1
View File
@@ -18,7 +18,7 @@ let package = Package(
.package(url: "https://github.com/antlr/antlr4", branch: "dev"), .package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")), .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"), .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.3.3"), .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
.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.0.0"), .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
], ],
+11 -2
View File
@@ -6,9 +6,18 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722). * Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
* Push/Pull virtual machines from any OCI-compatible container registry. * Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation. * Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration. * Easily integrates with any CI system.
*Tart* is already adopted by several automation services: Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Tart is also adopted by several other automation services:
<p align="center"> <p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank> <a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+21 -3
View File
@@ -3,7 +3,20 @@ import Foundation
import SystemConfiguration import SystemConfiguration
struct Clone: AsyncParsableCommand { struct Clone: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Clone a VM") static var configuration = CommandConfiguration(
abstract: "Clone a VM",
discussion: """
Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
"""
)
@Argument(help: "source VM name") @Argument(help: "source VM name")
var sourceName: String var sourceName: String
@@ -31,8 +44,6 @@ struct Clone: AsyncParsableCommand {
} }
let sourceVM = try VMStorageHelper.open(sourceName) let sourceVM = try VMStorageHelper.open(sourceName)
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()))
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection // Lock the temporary VM directory to prevent it's garbage collection
@@ -45,10 +56,17 @@ struct Clone: AsyncParsableCommand {
try lock.lock() try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress()) let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
&& sourceVM.state() != "suspended"
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir) try localStorage.move(newName, from: tmpVMDir)
try lock.unlock() try lock.unlock()
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
// is not actually claiming new space until the VM is started and it writes something to disk.
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
}, onCancel: { }, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL) try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
}) })
+2 -1
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Disk: Int let Disk: Int
let Display: String let Display: String
let Running: Bool let Running: Bool
let State: String
} }
struct Get: AsyncParsableCommand { struct Get: AsyncParsableCommand {
@@ -25,7 +26,7 @@ struct Get: AsyncParsableCommand {
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024 let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb, let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
Display: vmConfig.display.description, Running: try vmDir.running()) Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
print(format.renderSingle(info)) print(format.renderSingle(info))
} }
} }
+3 -2
View File
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
let Name: String let Name: String
let Size: Int let Size: Int
let Running: Bool let Running: Bool
let State: String
} }
struct List: AsyncParsableCommand { struct List: AsyncParsableCommand {
@@ -36,13 +37,13 @@ struct List: AsyncParsableCommand {
if source == nil || source == "local" { if source == nil || source == "local" {
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running()) try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
}) })
} }
if source == nil || source == "oci" { if source == nil || source == "oci" {
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running()) try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
}) })
} }
+57 -21
View File
@@ -5,23 +5,40 @@ import SwiftUI
import SwiftDate import SwiftDate
struct Prune: AsyncParsableCommand { struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches") static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago", @Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.", discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n")) valueName: "n"))
var olderThan: UInt? var olderThan: UInt?
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes", @Option(help: .hidden)
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
var cacheBudget: UInt? var cacheBudget: UInt?
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
valueName: "n"))
var spaceBudget: UInt?
@Flag(help: .hidden) @Flag(help: .hidden)
var gc: Bool = false var gc: Bool = false
func validate() throws { mutating func validate() throws {
if olderThan == nil && cacheBudget == nil && !gc { // --cache-budget deprecation logic
if let cacheBudget = cacheBudget {
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
if spaceBudget != nil {
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
}
spaceBudget = cacheBudget
}
if olderThan == nil && spaceBudget == nil && !gc {
throw ValidationError("at least one pruning criteria must be specified") throw ValidationError("at least one pruning criteria must be specified")
} }
} }
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
try VMStorageOCI().gc() try VMStorageOCI().gc()
} }
// Build a list of prunable storages that we're going to prune based on user's request
let prunableStorages: [PrunableStorage]
switch entries {
case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
// Clean up cache entries based on last accessed date // Clean up cache entries based on last accessed date
if let olderThan = olderThan { if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date().addingTimeInterval(olderThanInterval) let olderThanDate = Date() - olderThanInterval
try Prune.pruneOlderThan(olderThanDate: olderThanDate) try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
} }
// Clean up cache entries based on imposed cache size limit and entry's last accessed date // Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget { if let spaceBudget = spaceBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024) try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
} }
} }
static func pruneOlderThan(olderThanDate: Date) throws { static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() } let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() } try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
} }
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws { static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() } .flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() } .sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = [] var prunablesToDelete: [Prunable] = []
for prunable in prunables { for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.sizeBytes()) let prunableSizeBytes = UInt64(try prunable.sizeBytes())
if prunableSizeBytes <= cacheBudgetBytes { if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as // Don't mark for deletion as
// there's a budget available // there's a budget available
cacheBudgetBytes -= prunableSizeBytes spaceBudgetBytes -= prunableSizeBytes
} else { } else {
// Mark for deletion // Mark for deletion
prunablesToDelete.append(prunable) prunablesToDelete.append(prunable)
@@ -77,7 +104,11 @@ struct Prune: AsyncParsableCommand {
try prunablesToDelete.forEach { try $0.delete() } try prunablesToDelete.forEach { try $0.delete() }
} }
static func reclaimIfNeeded(_ requiredBytes: UInt64) throws { static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
return
}
SentrySDK.configureScope { scope in SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune") scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
} }
@@ -114,10 +145,10 @@ struct Prune: AsyncParsableCommand {
return return
} }
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated) try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
} }
private static func reclaimIfPossible(_ reclaimBytes: UInt64) throws { private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true) let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() } defer { transaction.finish() }
@@ -141,6 +172,11 @@ struct Prune: AsyncParsableCommand {
break break
} }
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path) try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.sizeBytes() cacheReclaimedBytes += try prunable.sizeBytes()
+10 -1
View File
@@ -3,7 +3,16 @@ import Dispatch
import SwiftUI import SwiftUI
struct Pull: AsyncParsableCommand { struct Pull: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry") static var configuration = CommandConfiguration(
abstract: "Pull a VM from a registry",
discussion: """
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@Argument(help: "remote VM name") @Argument(help: "remote VM name")
var remoteName: String var remoteName: String
+1 -1
View File
@@ -100,7 +100,7 @@ struct Push: AsyncParsableCommand {
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest) _ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
} }
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, return RemoteName(host: registry.host!, namespace: registry.namespace,
reference: Reference(digest: digest)) reference: Reference(digest: digest))
} }
} }
+155 -41
View File
@@ -73,13 +73,17 @@ struct Run: AsyncParsableCommand {
var rosettaTag: String? var rosettaTag: String?
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\") Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
""", discussion: """ """, discussion: """
Requires host to be macOS 13.0 (Ventura) or newer. Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS, A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer. For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]"))
In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\"
These names will be used as directory names under the mounting point inside guests. For the example above it will be
"/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
""", valueName: "[name:]path[:ro]"))
var dir: [String] = [] var dir: [String] = []
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
@@ -93,10 +97,14 @@ struct Run: AsyncParsableCommand {
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet")) discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false var netSoftnet: Bool = false
func validate() throws { @Flag(help: ArgumentHelp("Disables audio and entropy devices and switches to only Mac-specific input devices.", discussion: "Useful for running a VM that can be suspended via \"tart suspend\"."))
var suspendable: Bool = false
mutating func validate() throws {
if vnc && vncExperimental { if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
} }
if netBridged != nil && netSoftnet { if netBridged != nil && netSoftnet {
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive") throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
} }
@@ -104,11 +112,33 @@ struct Run: AsyncParsableCommand {
if graphics && noGraphics { if graphics && noGraphics {
throw ValidationError("--graphics and --no-graphics are mutually exclusive") throw ValidationError("--graphics and --no-graphics are mutually exclusive")
} }
let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
if try vmDir.state() == "suspended" {
suspendable = true
}
} }
@MainActor @MainActor
func run() async throws { func run() async throws {
let vmDir = try VMStorageLocal().open(name) let localStorage = VMStorageLocal()
let vmDir = try localStorage.open(name)
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
if try vmDir.state() == "suspended" {
try storageLock.lock() // lock before checking
let needToGenerateNewMac = try localStorage.list().contains {
// check if there is a running VM with the same MAC but different name
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
}
if needToGenerateNewMac {
print("There is already a running VM with the same MAC address!")
print("Resetting VM to assign a new MAC address...")
try vmDir.regenerateMACAddress()
}
}
if netSoftnet && isInteractiveSession() { if netSoftnet && isInteractiveSession() {
try Softnet.configureSUIDBitIfNeeded() try Softnet.configureSUIDBitIfNeeded()
@@ -153,7 +183,8 @@ struct Run: AsyncParsableCommand {
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(), network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare(), directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
serialPorts: serialPorts serialPorts: serialPorts,
suspendable: suspendable
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -184,8 +215,25 @@ struct Run: AsyncParsableCommand {
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!") throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
} }
// now VM state will return "running" so we can unlock
try storageLock.unlock()
let task = Task { let task = Task {
do { do {
var resume = false
if #available(macOS 14, *) {
if FileManager.default.fileExists(atPath: vmDir.stateURL.path) {
print("restoring VM state from a snapshot...")
try await vm!.virtualMachine.restoreMachineStateFrom(url: vmDir.stateURL)
try FileManager.default.removeItem(at: vmDir.stateURL)
resume = true
print("resuming VM...")
}
}
try await vm!.start(recovery: recovery, resume: resume)
if let vncImpl = vncImpl { if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL() let vncURL = try await vncImpl.waitForURL()
@@ -197,7 +245,7 @@ struct Run: AsyncParsableCommand {
} }
} }
try await vm!.run(recovery) try await vm!.run()
if let vncImpl = vncImpl { if let vncImpl = vncImpl {
try vncImpl.stop() try vncImpl.stop()
@@ -215,17 +263,50 @@ struct Run: AsyncParsableCommand {
} }
} }
// "tart stop" support
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT) let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
sigintSrc.setEventHandler { sigintSrc.setEventHandler {
task.cancel() task.cancel()
} }
sigintSrc.activate() sigintSrc.activate()
// "tart suspend" / UI window closing support
signal(SIGUSR1, SIG_IGN)
let sigusr1Src = DispatchSource.makeSignalSource(signal: SIGUSR1)
sigusr1Src.setEventHandler {
Task {
do {
if #available(macOS 14, *) {
try vm!.configuration.validateSaveRestoreSupport()
print("pausing VM to take a snapshot...")
try await vm!.virtualMachine.pause()
print("creating a snapshot...")
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
print("snapshot created successfully! shutting down the VM...")
task.cancel()
} else {
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
Foundation.exit(1)
}
} catch (let e) {
print(RuntimeError.SuspendFailed(e.localizedDescription))
Foundation.exit(1)
}
}
}
sigusr1Src.activate()
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
if noGraphics || useVNCWithoutGraphics { if noGraphics || useVNCWithoutGraphics {
dispatchMain() dispatchMain()
} else { } else {
runUI() runUI(suspendable)
} }
} }
@@ -315,46 +396,31 @@ struct Run: AsyncParsableCommand {
throw UnsupportedOSError("directory sharing", "is") throw UnsupportedOSError("directory sharing", "is")
} }
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
var directoryShares: [DirectoryShare] = [] var directoryShares: [DirectoryShare] = []
var allNamedShares = true
for rawDir in dir { for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" } let directoryShare = try DirectoryShare(parseFrom: rawDir)
if (directoryShare.name == nil) {
if splits.count < 2 { allNamedShares = false
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
} }
directoryShares.append(directoryShare)
var readOnly: Bool = false
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
}
let (name, path) = (String(splits[0]), String(splits[1]))
directoryShares.append(DirectoryShare(
name: name,
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
readOnly: readOnly)
)
} }
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag) let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories) if allNamedShares {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name!] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
} else if dir.count > 1 {
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
} else if dir.count == 1 {
let directoryShare = directoryShares.first!
let singleDirectoryShare = VZSingleDirectoryShare(directory: VZSharedDirectory(url: directoryShare.path, readOnly: directoryShare.readOnly))
sharingDevice.share = singleDirectoryShare
}
return [sharingDevice] return [sharingDevice]
} }
@@ -384,7 +450,7 @@ struct Run: AsyncParsableCommand {
return [device] return [device]
} }
private func runUI() { private func runUI(_ suspendable: Bool) {
let nsApp = NSApplication.shared let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular) nsApp.setActivationPolicy(.regular)
nsApp.activate(ignoringOtherApps: true) nsApp.activate(ignoringOtherApps: true)
@@ -392,6 +458,8 @@ struct Run: AsyncParsableCommand {
nsApp.applicationIconImage = NSImage(data: AppIconData) nsApp.applicationIconImage = NSImage(data: AppIconData)
struct MainApp: App { struct MainApp: App {
static var disappearSignal: Int32 = SIGINT
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate @NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
var body: some Scene { var body: some Scene {
@@ -400,7 +468,7 @@ struct Run: AsyncParsableCommand {
VMView(vm: vm!).onAppear { VMView(vm: vm!).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear { }.onDisappear {
let ret = kill(getpid(), SIGINT) let ret = kill(getpid(), MainApp.disappearSignal)
if ret != 0 { if ret != 0 {
// Fallback to the old termination method that doesn't // Fallback to the old termination method that doesn't
// propagate the cancellation to Task's in case graceful // propagate the cancellation to Task's in case graceful
@@ -436,11 +504,17 @@ struct Run: AsyncParsableCommand {
Button("Request Stop") { Button("Request Stop") {
Task { try vm!.virtualMachine.requestStop() } Task { try vm!.virtualMachine.requestStop() }
} }
if #available(macOS 14, *) {
Button("Suspend") {
kill(getpid(), SIGUSR1)
}
}
} }
} }
} }
} }
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
MainApp.main() MainApp.main()
} }
} }
@@ -512,3 +586,43 @@ struct VMView: NSViewRepresentable {
nsView.virtualMachine = vm.virtualMachine nsView.virtualMachine = vm.virtualMachine
} }
} }
struct DirectoryShare {
let name: String?
let path: URL
let readOnly: Bool
init(parseFrom: String) throws {
let splits = parseFrom.split(maxSplits: 2) { $0 == ":" }
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
} else if splits.count == 2 {
if splits[1] == "ro" {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = true
} else {
name = String(splits[0])
path = String(splits[1]).toFilePathURL()
readOnly = false
}
} else {
name = nil
path = String(splits[0]).toFilePathURL()
readOnly = false
}
}
}
extension String {
func toFilePathURL() -> URL {
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
}
}
+15
View File
@@ -14,6 +14,21 @@ struct Stop: AsyncParsableCommand {
func run() async throws { func run() async throws {
let vmDir = try VMStorageLocal().open(name) let vmDir = try VMStorageLocal().open(name)
switch try vmDir.state() {
case "suspended":
try stopSuspended(vmDir)
case "running":
try await stopRunning(vmDir)
default:
return
}
}
func stopSuspended(_ vmDir: VMDirectory) throws {
try? FileManager.default.removeItem(at: vmDir.stateURL)
}
func stopRunning(_ vmDir: VMDirectory) async throws {
let lock = try PIDLock(lockURL: vmDir.configURL) let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID // Find the VM's PID
+28
View File
@@ -0,0 +1,28 @@
import ArgumentParser
import Foundation
import System
import SwiftDate
struct Suspend: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
@Argument(help: "VM name")
var name: String
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID
var pid = try lock.pid()
if pid == 0 {
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
}
// Tell the "tart run" process to suspend the VM
let ret = kill(pid, SIGUSR1)
if ret != 0 {
throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
}
}
}
@@ -2,6 +2,11 @@ import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider { class EnvironmentCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? { func retrieve(host: String) throws -> (String, String)? {
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
tartRegistryHostname != host {
return nil
}
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"] let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"] let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
if let username = username, let password = password { if let username = username, let password = password {
+10 -4
View File
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
} }
let table = TextTable<T> { (item: T) in let table = TextTable<T> { (item: T) in
let mirroredObject = Mirror(reflecting: item) let mirroredObject = Mirror(reflecting: item)
return mirroredObject.children.enumerated().map { (_, element) in return mirroredObject.children.enumerated()
let fieldName = element.label! .filter {(_, element) in
return Column(title: fieldName, value: element.value) // Deprecate the "Running" field: only make it available
} // from JSON for backwards-compatibility
element.label! != "Running"
}
.map { (_, element) in
let fieldName = element.label!
return Column(title: fieldName, value: element.value)
}
} }
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
case .json: case .json:
+11 -4
View File
@@ -5,6 +5,7 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
// Annotations // Annotations
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size" let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
struct OCIManifest: Codable, Equatable { struct OCIManifest: Codable, Equatable {
var schemaVersion: Int = 2 var schemaVersion: Int = 2
@@ -13,15 +14,21 @@ struct OCIManifest: Codable, Equatable {
var layers: [OCIManifestLayer] = Array() var layers: [OCIManifestLayer] = Array()
var annotations: Dictionary<String, String>? var annotations: Dictionary<String, String>?
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) { init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
self.config = config self.config = config
self.layers = layers self.layers = layers
var annotations: [String: String] = [:]
if let uncompressedDiskSize = uncompressedDiskSize { if let uncompressedDiskSize = uncompressedDiskSize {
annotations = [ annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
]
} }
if let uploadDate = uploadDate {
annotations[uploadTimeAnnotation] = uploadDate.toISO()
}
self.annotations = annotations
} }
init(fromJSON: Data) throws { init(fromJSON: Data) throws {
+11 -1
View File
@@ -99,12 +99,22 @@ struct TokenResponse: Decodable, Authentication {
} }
class Registry { class Registry {
let baseURL: URL private let baseURL: URL
let namespace: String let namespace: String
let credentialsProviders: [CredentialsProvider] let credentialsProviders: [CredentialsProvider]
var currentAuthToken: Authentication? = nil var currentAuthToken: Authentication? = nil
var host: String? {
guard let host = baseURL.host else { return nil }
if let port = baseURL.port {
return "\(host):\(port)"
}
return host
}
init(urlComponents: URLComponents, init(urlComponents: URLComponents,
namespace: String, namespace: String,
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()] credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
+5
View File
@@ -8,6 +8,11 @@ class PIDLock {
init(lockURL: URL) throws { init(lockURL: URL) throws {
url = lockURL url = lockURL
fd = open(lockURL.path, O_RDWR) fd = open(lockURL.path, O_RDWR)
if fd == -1 {
let details = Errno(rawValue: CInt(errno))
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
}
} }
deinit { deinit {
+17 -1
View File
@@ -6,7 +6,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
} }
} }
struct Darwin: Platform { struct Darwin: PlatformSuspendable {
var ecid: VZMacMachineIdentifier var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel var hardwareModel: VZMacHardwareModel
@@ -107,6 +107,14 @@ struct Darwin: Platform {
} }
} }
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
if #available(macOS 14, *) {
return [VZMacKeyboardConfiguration()]
} else {
return []
}
}
func pointingDevices() -> [VZPointingDeviceConfiguration] { func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) { if #available(macOS 13, *) {
// Trackpad is only supported by guests starting with macOS Ventura // Trackpad is only supported by guests starting with macOS Ventura
@@ -115,4 +123,12 @@ struct Darwin: Platform {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()] return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
} }
} }
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
return [VZMacTrackpadConfiguration()]
} else {
return []
}
}
} }
+5
View File
@@ -8,3 +8,8 @@ protocol Platform: Codable {
func keyboards() -> [VZKeyboardConfiguration] func keyboards() -> [VZKeyboardConfiguration]
func pointingDevices() -> [VZPointingDeviceConfiguration] func pointingDevices() -> [VZPointingDeviceConfiguration]
} }
protocol PlatformSuspendable: Platform {
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
}
+5
View File
@@ -57,6 +57,11 @@ struct Root: AsyncParsableCommand {
} }
} }
// Add commands that are only available on specific macOS versions
if #available(macOS 14, *) {
configuration.subcommands.append(Suspend.self)
}
// Ensure the default SIGINT handled is disabled, // Ensure the default SIGINT handled is disabled,
// otherwise there's a race between two handlers // otherwise there's a race between two handlers
signal(SIGINT, SIG_IGN); signal(SIGINT, SIG_IGN);
+64 -24
View File
@@ -26,6 +26,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine // Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine @Published var virtualMachine: VZVirtualMachine
// Virtualization.Framework's virtual machine configuration
var configuration: VZVirtualMachineConfiguration
// Semaphore used to communicate with the VZVirtualMachineDelegate // Semaphore used to communicate with the VZVirtualMachineDelegate
var sema = DispatchSemaphore(value: 0) var sema = DispatchSemaphore(value: 0)
@@ -41,7 +44,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [], additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [], directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
serialPorts: [VZSerialPortConfiguration] = [] serialPorts: [VZSerialPortConfiguration] = [],
suspendable: Bool = false
) throws { ) throws {
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -52,11 +56,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config, nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments, network: network, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices, directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts serialPorts: serialPorts,
suspendable: suspendable
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -179,11 +184,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
self.network = network self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL, configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network, vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices, directorySharingDevices: directorySharingDevices,
serialPorts: serialPorts serialPorts: serialPorts
) )
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
@@ -220,11 +225,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
return try VM(vmDir: vmDir) return try VM(vmDir: vmDir)
} }
func run(_ recovery: Bool) async throws { func start(recovery: Bool, resume shouldResume: Bool) async throws {
try network.run(sema) try network.run(sema)
try await start(recovery) if shouldResume {
try await resume()
} else {
try await start(recovery)
}
}
func run() async throws {
await withTaskCancellationHandler(operation: { await withTaskCancellationHandler(operation: {
// Wait for the VM to finish running // Wait for the VM to finish running
// or for the exit condition // or for the exit condition
@@ -253,6 +264,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@MainActor
private func resume() async throws {
try await virtualMachine.resume()
}
@MainActor @MainActor
private func stop() async throws { private func stop() async throws {
try await self.virtualMachine.stop() try await self.virtualMachine.stop()
@@ -265,7 +281,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
network: Network = NetworkShared(), network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment], additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration], directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
serialPorts: [VZSerialPortConfiguration] serialPorts: [VZSerialPortConfiguration],
suspendable: Bool = false
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
@@ -283,17 +300,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)] configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() if !suspendable {
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource() let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration() inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink() let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration] outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
configuration.audioDevices = [soundDeviceConfiguration] soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
configuration.audioDevices = [soundDeviceConfiguration]
}
// Keyboard and mouse // Keyboard and mouse
configuration.keyboards = vmConfig.platform.keyboards() if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
configuration.pointingDevices = vmConfig.platform.pointingDevices() configuration.keyboards = platformSuspendable.keyboardsSuspendable()
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
} else {
configuration.keyboards = vmConfig.platform.keyboards()
configuration.pointingDevices = vmConfig.platform.pointingDevices()
}
// Networking // Networking
let vio = VZVirtioNetworkDeviceConfiguration() let vio = VZVirtioNetworkDeviceConfiguration()
@@ -307,7 +331,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) } configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
// Entropy // Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] if !suspendable {
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
}
// Directory sharing devices // Directory sharing devices
configuration.directorySharingDevices = directorySharingDevices configuration.directorySharingDevices = directorySharingDevices
@@ -315,6 +341,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Serial Port // Serial Port
configuration.serialPorts = serialPorts configuration.serialPorts = serialPorts
// Version console device
//
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
if #available(macOS 13, *) {
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice)
}
try configuration.validate() try configuration.validate()
return configuration return configuration
+3 -2
View File
@@ -148,7 +148,8 @@ extension VMDirectory {
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers, layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset) uncompressedDiskSize: UInt64(mappedDiskReadOffset),
uploadDate: Date()
) )
// Manifest // Manifest
@@ -159,7 +160,7 @@ extension VMDirectory {
} }
let pushedReference = Reference(digest: try manifest.digest()) let pushedReference = Reference(digest: try manifest.digest())
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference) return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
} }
} }
+26 -1
View File
@@ -13,6 +13,9 @@ struct VMDirectory: Prunable {
var nvramURL: URL { var nvramURL: URL {
baseURL.appendingPathComponent("nvram.bin") baseURL.appendingPathComponent("nvram.bin")
} }
var stateURL: URL {
baseURL.appendingPathComponent("state.vzvmsave")
}
var explicitlyPulledMark: URL { var explicitlyPulledMark: URL {
baseURL.appendingPathComponent(".explicitly-pulled") baseURL.appendingPathComponent(".explicitly-pulled")
@@ -27,7 +30,26 @@ struct VMDirectory: Prunable {
} }
func running() throws -> Bool { func running() throws -> Bool {
try PIDLock(lockURL: configURL).pid() != 0 // The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
// which is fine to report as "not running".
//
// The other reasons are unlikely and the cost of getting a false positive is way less than
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
guard let lock = try? PIDLock(lockURL: configURL) else {
return false
}
return try lock.pid() != 0
}
func state() throws -> String {
if try running() {
return "running"
} else if FileManager.default.fileExists(atPath: stateURL.path) {
return "suspended"
} else {
return "stopped"
}
} }
static func temporary() throws -> VMDirectory { static func temporary() throws -> VMDirectory {
@@ -70,6 +92,7 @@ struct VMDirectory: Prunable {
try FileManager.default.copyItem(at: configURL, to: to.configURL) try FileManager.default.copyItem(at: configURL, to: to.configURL)
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL) try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
try FileManager.default.copyItem(at: diskURL, to: to.diskURL) try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
// Re-generate MAC address // Re-generate MAC address
if generateMAC { if generateMAC {
@@ -85,6 +108,8 @@ struct VMDirectory: Prunable {
var vmConfig = try VMConfig(fromURL: configURL) var vmConfig = try VMConfig(fromURL: configURL)
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered() vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
// cleanup state if any
try? FileManager.default.removeItem(at: stateURL)
try vmConfig.save(toURL: configURL) try vmConfig.save(toURL: configURL)
} }
+3
View File
@@ -58,6 +58,7 @@ enum RuntimeError : Error {
case ImportFailed(_ message: String) case ImportFailed(_ message: String)
case SoftnetFailed(_ message: String) case SoftnetFailed(_ message: String)
case OCIStorageError(_ message: String) case OCIStorageError(_ message: String)
case SuspendFailed(_ message: String)
} }
protocol HasExitCode { protocol HasExitCode {
@@ -101,6 +102,8 @@ extension RuntimeError : CustomStringConvertible {
return "Softnet failed: \(message)" return "Softnet failed: \(message)"
case .OCIStorageError(let message): case .OCIStorageError(let message):
return "OCI storage error: \(message)" return "OCI storage error: \(message)"
case .SuspendFailed(let message):
return "Failed to suspend the VM: \(message)"
} }
} }
} }
+7 -1
View File
@@ -1,6 +1,6 @@
import Foundation import Foundation
class VMStorageLocal { class VMStorageLocal: PrunableStorage {
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL { private func vmURL(_ name: String) -> URL {
@@ -16,6 +16,8 @@ class VMStorageLocal {
try vmDir.validate(userFriendlyName: name) try vmDir.validate(userFriendlyName: name)
try vmDir.baseURL.updateAccessDate()
return vmDir return vmDir
} }
@@ -63,6 +65,10 @@ class VMStorageLocal {
} }
} }
func prunables() throws -> [Prunable] {
try list().map { (_, vmDir) in vmDir }
}
func hasVMsWithMACAddress(macAddress: String) throws -> Bool { func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress } try list().contains { try $1.macAddress() == macAddress }
} }
+32
View File
@@ -0,0 +1,32 @@
import XCTest
@testable import tart
final class DirectoryShareTests: XCTestCase {
func testNamedParsing() throws {
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
XCTAssertEqual(share.name, "build")
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertFalse(share.readOnly)
}
func testNamedReadOnlyParsing() throws {
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
XCTAssertEqual(share.name, "build")
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertTrue(share.readOnly)
}
func testOptionalNameParsing() throws {
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
XCTAssertNil(share.name)
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertFalse(share.readOnly)
}
func testOptionalNameReadOnlyParsing() throws {
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
XCTAssertNil(share.name)
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
XCTAssertTrue(share.readOnly)
}
}
+1
View File
@@ -28,5 +28,6 @@ jobs:
``` ```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`. When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
![](/assets/images/TartGHARunners.png) ![](/assets/images/TartGHARunners.png)
+11
View File
@@ -70,6 +70,17 @@ The directory we've mounted above will be accessible from the `/Volumes/My Share
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer. Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
??? tip "Changing mount location"
It is possible to remount the directories after a virtual machine is started by running the following commands:
```bash
sudo umount "/Volumes/My Shared Files"
mkdir ~/workspace
mount_virtiofs com.apple.virtio-fs.automount ~/workspace
```
After running the above commands the direcory will be available at `~/workspace/project`
### Accessing mounted directories in Linux guests ### Accessing mounted directories in Linux guests
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first: To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
+1 -1
View File
@@ -81,7 +81,7 @@
} }
</style> </style>
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script> <script src="https://unpkg.com/@dotlottie/player-component@1.4.0/dist/dotlottie-player.js"></script>
<!-- landing page for landing page --> <!-- landing page for landing page -->
<!-- Hero --> <!-- Hero -->