mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-18 11:55:36 +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:
|
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
@@ -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
@@ -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"),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 |
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
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.
|
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:
|
||||||
|
|||||||
Vendored
+1
-1
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user