mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 | ||
|
|
93a1b70ecb | ||
|
|
cf9a3a9221 | ||
|
|
ad566faa23 | ||
|
|
1afaa7ec7a | ||
|
|
826e508646 | ||
|
|
8653ca4115 | ||
|
|
63b74f407b | ||
|
|
3a2cba6929 | ||
|
|
7592b86663 | ||
|
|
e8dbb86fc0 | ||
|
|
d2ed4ef801 | ||
|
|
2014de7dac | ||
|
|
1f23b24920 | ||
|
|
6ce4a06089 |
+10
-2
@@ -6,6 +6,7 @@ env:
|
||||
task:
|
||||
name: Test on Ventura
|
||||
alias: test
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: dev-mini
|
||||
@@ -30,6 +31,7 @@ task:
|
||||
task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
lint_script:
|
||||
@@ -40,9 +42,10 @@ task:
|
||||
format: swiftformat
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
alias: build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
build_script: swift build --product tart
|
||||
@@ -51,11 +54,12 @@ task:
|
||||
path: .build/debug/tart
|
||||
|
||||
task:
|
||||
name: Release (Dry Run)
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
name: Release (Dry Run)
|
||||
depends_on:
|
||||
- lint
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
env:
|
||||
@@ -91,6 +95,7 @@ task:
|
||||
- lint
|
||||
- test
|
||||
- build
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||
env:
|
||||
@@ -123,9 +128,11 @@ task:
|
||||
# Generate and upload symbols
|
||||
- dsymutil tart
|
||||
- sentry-cli debug-files upload tart.dSYM/
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||
# Bundle and upload sources
|
||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||
- sentry-cli debug-files upload tart.src.zip
|
||||
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||
create_sentry_release_script:
|
||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||
- sentry-cli releases new $SENTRY_RELEASE
|
||||
@@ -135,6 +142,7 @@ task:
|
||||
task:
|
||||
name: Deploy Documentation
|
||||
only_if: $CIRRUS_BRANCH == 'main'
|
||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
||||
container:
|
||||
image: ghcr.io/cirruslabs/mkdocs-material-insiders:latest
|
||||
registry_config: ENCRYPTED[!cf1a0f25325aa75bad3ce6ebc890bc53eb0044c02efa70d8cefb83ba9766275a994b4831706c52630a0692b2fa9cfb9e!]
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852",
|
||||
"version" : "8.3.3"
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ let package = Package(
|
||||
.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/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/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
|
||||
],
|
||||
|
||||
@@ -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).
|
||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||
* 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">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -3,7 +3,20 @@ import Foundation
|
||||
import SystemConfiguration
|
||||
|
||||
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")
|
||||
var sourceName: String
|
||||
@@ -31,8 +44,6 @@ struct Clone: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()))
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
@@ -45,10 +56,17 @@ struct Clone: AsyncParsableCommand {
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
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: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Disk: Int
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
|
||||
struct Get: AsyncParsableCommand {
|
||||
@@ -25,7 +26,7 @@ struct Get: AsyncParsableCommand {
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
let Name: String
|
||||
let Size: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
|
||||
struct List: AsyncParsableCommand {
|
||||
@@ -36,13 +37,13 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
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" {
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,40 @@ import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
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.",
|
||||
valueName: "n"))
|
||||
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",
|
||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||
valueName: "n"))
|
||||
@Option(help: .hidden)
|
||||
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)
|
||||
var gc: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if olderThan == nil && cacheBudget == nil && !gc {
|
||||
mutating func validate() throws {
|
||||
// --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")
|
||||
}
|
||||
}
|
||||
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
|
||||
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
|
||||
if let olderThan = olderThan {
|
||||
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
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
if let spaceBudget = spaceBudget {
|
||||
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
|
||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||
|
||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
var cacheBudgetBytes = cacheBudgetBytes
|
||||
var spaceBudgetBytes = spaceBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
|
||||
if prunableSizeBytes <= cacheBudgetBytes {
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
cacheBudgetBytes -= prunableSizeBytes
|
||||
spaceBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
@@ -77,7 +104,11 @@ struct Prune: AsyncParsableCommand {
|
||||
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
|
||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||
}
|
||||
@@ -114,10 +145,10 @@ struct Prune: AsyncParsableCommand {
|
||||
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)
|
||||
defer { transaction.finish() }
|
||||
|
||||
@@ -141,6 +172,11 @@ struct Prune: AsyncParsableCommand {
|
||||
break
|
||||
}
|
||||
|
||||
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||
// do not prune the initiator
|
||||
continue
|
||||
}
|
||||
|
||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
|
||||
@@ -3,7 +3,16 @@ import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
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")
|
||||
var remoteName: String
|
||||
|
||||
@@ -100,7 +100,7 @@ struct Push: AsyncParsableCommand {
|
||||
_ = 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))
|
||||
}
|
||||
}
|
||||
|
||||
+155
-41
@@ -73,13 +73,17 @@ struct Run: AsyncParsableCommand {
|
||||
var rosettaTag: String?
|
||||
|
||||
@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: """
|
||||
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".
|
||||
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] = []
|
||||
|
||||
@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"))
|
||||
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 {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged != nil && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
@@ -104,11 +112,33 @@ struct Run: AsyncParsableCommand {
|
||||
if graphics && noGraphics {
|
||||
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
|
||||
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() {
|
||||
try Softnet.configureSUIDBitIfNeeded()
|
||||
@@ -153,7 +183,8 @@ struct Run: AsyncParsableCommand {
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||
serialPorts: serialPorts
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -184,8 +215,25 @@ struct Run: AsyncParsableCommand {
|
||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||
}
|
||||
|
||||
// now VM state will return "running" so we can unlock
|
||||
try storageLock.unlock()
|
||||
|
||||
let task = Task {
|
||||
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 {
|
||||
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 {
|
||||
try vncImpl.stop()
|
||||
@@ -215,17 +263,50 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
// "tart stop" support
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
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
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
runUI(suspendable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,46 +396,31 @@ struct Run: AsyncParsableCommand {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
}
|
||||
|
||||
var directoryShares: [DirectoryShare] = []
|
||||
|
||||
var allNamedShares = true
|
||||
for rawDir in dir {
|
||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||
|
||||
if splits.count < 2 {
|
||||
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
|
||||
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
||||
if (directoryShare.name == nil) {
|
||||
allNamedShares = false
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
directoryShares.append(directoryShare)
|
||||
}
|
||||
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
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]
|
||||
}
|
||||
@@ -384,7 +450,7 @@ struct Run: AsyncParsableCommand {
|
||||
return [device]
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
private func runUI(_ suspendable: Bool) {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
@@ -392,6 +458,8 @@ struct Run: AsyncParsableCommand {
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
static var disappearSignal: Int32 = SIGINT
|
||||
|
||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||
|
||||
var body: some Scene {
|
||||
@@ -400,7 +468,7 @@ struct Run: AsyncParsableCommand {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
let ret = kill(getpid(), SIGINT)
|
||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
||||
if ret != 0 {
|
||||
// Fallback to the old termination method that doesn't
|
||||
// propagate the cancellation to Task's in case graceful
|
||||
@@ -436,11 +504,17 @@ struct Run: AsyncParsableCommand {
|
||||
Button("Request Stop") {
|
||||
Task { try vm!.virtualMachine.requestStop() }
|
||||
}
|
||||
if #available(macOS 14, *) {
|
||||
Button("Suspend") {
|
||||
kill(getpid(), SIGUSR1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
@@ -512,3 +586,43 @@ struct VMView: NSViewRepresentable {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ struct Stop: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
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)
|
||||
|
||||
// Find the VM's PID
|
||||
|
||||
@@ -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 {
|
||||
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 password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||
if let username = username, let password = password {
|
||||
|
||||
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
|
||||
}
|
||||
let table = TextTable<T> { (item: T) in
|
||||
let mirroredObject = Mirror(reflecting: item)
|
||||
return mirroredObject.children.enumerated().map { (_, element) in
|
||||
let fieldName = element.label!
|
||||
return Column(title: fieldName, value: element.value)
|
||||
}
|
||||
return mirroredObject.children.enumerated()
|
||||
.filter {(_, element) in
|
||||
// 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) ?? ""
|
||||
case .json:
|
||||
|
||||
@@ -5,6 +5,7 @@ let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
// Annotations
|
||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||
|
||||
struct OCIManifest: Codable, Equatable {
|
||||
var schemaVersion: Int = 2
|
||||
@@ -13,15 +14,21 @@ struct OCIManifest: Codable, Equatable {
|
||||
var layers: [OCIManifestLayer] = Array()
|
||||
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.layers = layers
|
||||
|
||||
var annotations: [String: String] = [:]
|
||||
|
||||
if let uncompressedDiskSize = uncompressedDiskSize {
|
||||
annotations = [
|
||||
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
|
||||
]
|
||||
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
|
||||
}
|
||||
|
||||
if let uploadDate = uploadDate {
|
||||
annotations[uploadTimeAnnotation] = uploadDate.toISO()
|
||||
}
|
||||
|
||||
self.annotations = annotations
|
||||
}
|
||||
|
||||
init(fromJSON: Data) throws {
|
||||
|
||||
@@ -99,12 +99,22 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
let baseURL: URL
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
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,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
|
||||
@@ -8,6 +8,11 @@ class PIDLock {
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
struct Darwin: Platform {
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
@@ -107,6 +107,14 @@ struct Darwin: Platform {
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
@@ -115,4 +123,12 @@ struct Darwin: Platform {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,8 @@ protocol Platform: Codable {
|
||||
func keyboards() -> [VZKeyboardConfiguration]
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
|
||||
protocol PlatformSuspendable: Platform {
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
// otherwise there's a race between two handlers
|
||||
signal(SIGINT, SIG_IGN);
|
||||
|
||||
+64
-24
@@ -26,6 +26,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
|
||||
// Virtualization.Framework's virtual machine configuration
|
||||
var configuration: VZVirtualMachineConfiguration
|
||||
|
||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||
var sema = DispatchSemaphore(value: 0)
|
||||
|
||||
@@ -41,7 +44,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -52,11 +56,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -179,11 +184,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -220,11 +225,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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 await start(recovery)
|
||||
if shouldResume {
|
||||
try await resume()
|
||||
} else {
|
||||
try await start(recovery)
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// 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
|
||||
private func stop() async throws {
|
||||
try await self.virtualMachine.stop()
|
||||
@@ -265,7 +281,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration]
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -283,17 +300,24 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
if !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
}
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||
} else {
|
||||
configuration.keyboards = vmConfig.platform.keyboards()
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
}
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
@@ -307,7 +331,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||
|
||||
// Entropy
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
if !suspendable {
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
}
|
||||
|
||||
// Directory sharing devices
|
||||
configuration.directorySharingDevices = directorySharingDevices
|
||||
@@ -315,6 +341,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Serial Port
|
||||
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()
|
||||
|
||||
return configuration
|
||||
|
||||
@@ -148,7 +148,8 @@ extension VMDirectory {
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset),
|
||||
uploadDate: Date()
|
||||
)
|
||||
|
||||
// Manifest
|
||||
@@ -159,7 +160,7 @@ extension VMDirectory {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@ struct VMDirectory: Prunable {
|
||||
var nvramURL: URL {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
var stateURL: URL {
|
||||
baseURL.appendingPathComponent("state.vzvmsave")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
@@ -27,7 +30,26 @@ struct VMDirectory: Prunable {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -70,6 +92,7 @@ struct VMDirectory: Prunable {
|
||||
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
||||
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
|
||||
|
||||
// Re-generate MAC address
|
||||
if generateMAC {
|
||||
@@ -85,6 +108,8 @@ struct VMDirectory: Prunable {
|
||||
var vmConfig = try VMConfig(fromURL: configURL)
|
||||
|
||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
// cleanup state if any
|
||||
try? FileManager.default.removeItem(at: stateURL)
|
||||
|
||||
try vmConfig.save(toURL: configURL)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ enum RuntimeError : Error {
|
||||
case ImportFailed(_ message: String)
|
||||
case SoftnetFailed(_ message: String)
|
||||
case OCIStorageError(_ message: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -101,6 +102,8 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "Softnet failed: \(message)"
|
||||
case .OCIStorageError(let message):
|
||||
return "OCI storage error: \(message)"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
class VMStorageLocal: PrunableStorage {
|
||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
@@ -16,6 +16,8 @@ class VMStorageLocal {
|
||||
|
||||
try vmDir.validate(userFriendlyName: name)
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -63,6 +65,10 @@ class VMStorageLocal {
|
||||
}
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().map { (_, vmDir) in vmDir }
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
try list().contains { try $1.macAddress() == macAddress }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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.
|
||||
|
||||
??? 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
|
||||
|
||||
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
|
||||
|
||||
Vendored
+1
-1
@@ -81,7 +81,7 @@
|
||||
}
|
||||
</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 -->
|
||||
<!-- Hero -->
|
||||
|
||||
Reference in New Issue
Block a user