mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 09:42:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050d6a6ff1 | ||
|
|
5eddd1ce41 | ||
|
|
35f5b30bc4 | ||
|
|
8b27fea745 | ||
|
|
e00f62c95a | ||
|
|
d90893e9a0 | ||
|
|
feb733a7c0 | ||
|
|
545b6fcd94 | ||
|
|
c750d63ac9 | ||
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 | ||
|
|
93a1b70ecb | ||
|
|
cf9a3a9221 | ||
|
|
ad566faa23 | ||
|
|
1afaa7ec7a | ||
|
|
826e508646 | ||
|
|
8653ca4115 | ||
|
|
63b74f407b | ||
|
|
3a2cba6929 | ||
|
|
7592b86663 | ||
|
|
e8dbb86fc0 | ||
|
|
d2ed4ef801 | ||
|
|
2014de7dac | ||
|
|
1f23b24920 | ||
|
|
6ce4a06089 | ||
|
|
1a2f187ac8 | ||
|
|
285bf9b6c2 | ||
|
|
415ed3388d | ||
|
|
870b414994 | ||
|
|
be7011bf11 | ||
|
|
859050cb42 | ||
|
|
4f321ec264 | ||
|
|
1b53ce42f8 | ||
|
|
e89ef32a83 | ||
|
|
62a34bf89f | ||
|
|
0608b2b9d1 | ||
|
|
91e859de9b |
+17
-6
@@ -1,8 +1,12 @@
|
|||||||
use_compute_credits: true
|
use_compute_credits: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
XCODE_TAG: 15-beta-5
|
||||||
|
|
||||||
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
|
||||||
@@ -27,8 +31,9 @@ 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:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||||
lint_script:
|
lint_script:
|
||||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||||
always:
|
always:
|
||||||
@@ -37,24 +42,26 @@ 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:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||||
build_script: swift build --product tart
|
build_script: swift build --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
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:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -88,8 +95,9 @@ 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:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -120,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
|
||||||
@@ -132,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!]
|
||||||
|
|||||||
+5
-7
@@ -4,6 +4,9 @@ before:
|
|||||||
hooks:
|
hooks:
|
||||||
- .ci/set-version.sh
|
- .ci/set-version.sh
|
||||||
- swift build -c release --product tart
|
- swift build -c release --product tart
|
||||||
|
- gon gon.hcl
|
||||||
|
- mkdir -p tart.app/Contents/MacOS
|
||||||
|
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- builder: prebuilt
|
- builder: prebuilt
|
||||||
@@ -11,11 +14,9 @@ builds:
|
|||||||
- darwin
|
- darwin
|
||||||
goarch:
|
goarch:
|
||||||
- arm64
|
- arm64
|
||||||
|
binary: tart.app/Contents/MacOS/tart
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: .build/arm64-apple-macosx/release/tart
|
path: tart.app/Contents/MacOS/tart
|
||||||
hooks:
|
|
||||||
post:
|
|
||||||
- gon gon.hcl
|
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "{{ .ProjectName }}"
|
- name_template: "{{ .ProjectName }}"
|
||||||
@@ -23,9 +24,6 @@ archives:
|
|||||||
- src: Resources/embedded.provisionprofile
|
- src: Resources/embedded.provisionprofile
|
||||||
dst: tart.app/Contents
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
- src: ".build/arm64-apple-macosx/release/tart"
|
|
||||||
dst: tart.app/Contents/MacOS
|
|
||||||
strip_parent: true
|
|
||||||
- LICENSE
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contributing to Tart
|
||||||
|
|
||||||
|
Table of Contents
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
- [How to Build](#how-to-build)
|
||||||
|
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
|
||||||
|
- [Style Guidelines](#style-guidelines)
|
||||||
|
- [Pull Requests](#Pull-Requests)
|
||||||
|
|
||||||
|
## How to Build
|
||||||
|
|
||||||
|
1. Fork the repository to your own GitHub account
|
||||||
|
2. Clone the forked repository to your local machine
|
||||||
|
3. If using Xcode, use from Xcode 15 or newer
|
||||||
|
4. Run ./scripts/run-signed.sh from the root of your repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-signed.sh list
|
||||||
|
```
|
||||||
|
## How to Create an Issue/Enhancement
|
||||||
|
|
||||||
|
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
|
||||||
|
2. Click on the "New Issue" button
|
||||||
|
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
|
||||||
|
4. Submit the issue
|
||||||
|
|
||||||
|
## Style Guidelines
|
||||||
|
|
||||||
|
1. Code should follow camel case
|
||||||
|
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||||
|
```bash
|
||||||
|
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Provide a detailed description of the changes you made in the pull request
|
||||||
|
2. Wait for pull request to be reviewed
|
||||||
|
3. Make adjustments if necessary
|
||||||
+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>
|
||||||
@@ -31,6 +40,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://krisp.ai/" target=_blank>
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
|
</a>
|
||||||
<a href="https://suran.com/" target=_blank>
|
<a href="https://suran.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 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,7 +44,6 @@ struct Clone: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
|
|
||||||
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
|
||||||
@@ -44,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())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "skip validation of the registry's credentials before logging-in")
|
||||||
|
var noValidate: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
let usernameProvided = username != nil
|
let usernameProvided = username != nil
|
||||||
let passwordProvided = passwordStdin
|
let passwordProvided = passwordStdin
|
||||||
@@ -45,12 +48,14 @@ struct Login: AsyncParsableCommand {
|
|||||||
host: (user, password)
|
host: (user, password)
|
||||||
])
|
])
|
||||||
|
|
||||||
do {
|
if !noValidate {
|
||||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
do {
|
||||||
credentialsProviders: [credentialsProvider])
|
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||||
try await registry.ping()
|
credentialsProviders: [credentialsProvider])
|
||||||
} catch {
|
try await registry.ping()
|
||||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
} catch {
|
||||||
|
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dispatch
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Logout: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
|
||||||
|
|
||||||
|
@Argument(help: "host")
|
||||||
|
var host: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
try KeychainCredentialsProvider().remove(host: host)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,54 @@ struct Prune: AsyncParsableCommand {
|
|||||||
try prunablesToDelete.forEach { try $0.delete() }
|
try prunablesToDelete.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneReclaim(reclaimBytes: 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figure out how much disk space is available
|
||||||
|
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||||
|
.volumeAvailableCapacityKey,
|
||||||
|
.volumeAvailableCapacityForImportantUsageKey
|
||||||
|
])
|
||||||
|
let volumeAvailableCapacityCalculated = max(
|
||||||
|
UInt64(attrs.volumeAvailableCapacity!),
|
||||||
|
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||||
|
)
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: [
|
||||||
|
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||||
|
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||||
|
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||||
|
], key: "Prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
if volumeAvailableCapacityCalculated <= 0 {
|
||||||
|
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||||
|
scope.setLevel(.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we know how much free space is left,
|
||||||
|
// check if we even need to reclaim anything
|
||||||
|
if requiredBytes < volumeAvailableCapacityCalculated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||||
|
defer { transaction.finish() }
|
||||||
|
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
@@ -98,10 +172,16 @@ struct Prune: AsyncParsableCommand {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||||
try prunable.delete()
|
// do not prune the initiator
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
|
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||||
|
|
||||||
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-60
@@ -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("""
|
||||||
@@ -87,28 +91,54 @@ struct Run: AsyncParsableCommand {
|
|||||||
""", discussion: """
|
""", discussion: """
|
||||||
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||||
""", valueName: "interface name"))
|
""", valueName: "interface name"))
|
||||||
var netBridged: String?
|
var netBridged: [String]
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||||
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.count > 0 && netSoftnet {
|
||||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,23 +331,19 @@ struct Run: AsyncParsableCommand {
|
|||||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let netBridged = netBridged {
|
if netBridged.count > 0 {
|
||||||
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
|
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||||
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
|
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
|
||||||
|
interface.identifier == name || interface.localizedDisplayName == name
|
||||||
|
}
|
||||||
|
if (interface == nil) {
|
||||||
|
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||||
|
+ "available interfaces: \(bridgeInterfaces())")
|
||||||
|
}
|
||||||
|
return interface!
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchingInterfaces.isEmpty {
|
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
|
||||||
let available = bridgeInterfaces().joined(separator: ", ")
|
|
||||||
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
|
||||||
+ "available interfaces: \(available)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if matchingInterfaces.count > 1 {
|
|
||||||
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
|
|
||||||
+ "consider refining the search criteria")
|
|
||||||
}
|
|
||||||
|
|
||||||
return NetworkBridged(interface: matchingInterfaces.first!)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -315,46 +392,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 +446,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 +454,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 +464,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
|
||||||
@@ -408,7 +472,14 @@ struct Run: AsyncParsableCommand {
|
|||||||
NSApplication.shared.terminate(self)
|
NSApplication.shared.terminate(self)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
}.frame(
|
||||||
|
minWidth: CGFloat(vm!.config.display.width),
|
||||||
|
idealWidth: CGFloat(vm!.config.display.width),
|
||||||
|
maxWidth: .infinity,
|
||||||
|
minHeight: CGFloat(vm!.config.display.height),
|
||||||
|
idealHeight: CGFloat(vm!.config.display.height),
|
||||||
|
maxHeight: .infinity
|
||||||
|
)
|
||||||
}.commands {
|
}.commands {
|
||||||
// Remove some standard menu options
|
// Remove some standard menu options
|
||||||
CommandGroup(replacing: .help, addition: {})
|
CommandGroup(replacing: .help, addition: {})
|
||||||
@@ -429,11 +500,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -486,8 +563,18 @@ struct VMView: NSViewRepresentable {
|
|||||||
|
|
||||||
func makeNSView(context: Context) -> NSViewType {
|
func makeNSView(context: Context) -> NSViewType {
|
||||||
let machineView = VZVirtualMachineView()
|
let machineView = VZVirtualMachineView()
|
||||||
// so keys like take a windows screenshot (cmd+shift+4+space) works on the host and not guest
|
|
||||||
|
// Do not capture system keys so that shortcuts like
|
||||||
|
// Shift-Command-4 + Space (capture a screenshot of window)
|
||||||
|
// work on the host instead of the guest
|
||||||
machineView.capturesSystemKeys = false
|
machineView.capturesSystemKeys = false
|
||||||
|
|
||||||
|
// Enable automatic display reconfiguration
|
||||||
|
// for guests that support it
|
||||||
|
if #available(macOS 14.0, *) {
|
||||||
|
machineView.automaticallyReconfiguresDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
return machineView
|
return machineView
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,3 +582,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 {
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remove(host: String) throws {
|
||||||
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
|
kSecAttrServer as String: host,
|
||||||
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
return
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OSStatus {
|
extension OSStatus {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
protocol Network {
|
protocol Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment
|
func attachments() -> [VZNetworkDeviceAttachment]
|
||||||
func run(_ sema: DispatchSemaphore) throws
|
func run(_ sema: DispatchSemaphore) throws
|
||||||
func stop() async throws
|
func stop() async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkBridged: Network {
|
class NetworkBridged: Network {
|
||||||
let interface: VZBridgedNetworkInterface
|
let interfaces: [VZBridgedNetworkInterface]
|
||||||
|
|
||||||
init(interface: VZBridgedNetworkInterface) {
|
init(interfaces: [VZBridgedNetworkInterface]) {
|
||||||
self.interface = interface
|
self.interfaces = interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkShared: Network {
|
class NetworkShared: Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZNATNetworkDeviceAttachment()
|
[VZNATNetworkDeviceAttachment()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func configureSUIDBitIfNeeded() throws {
|
static func configureSUIDBitIfNeeded() throws {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
let defaultIssuedAt = Date()
|
let defaultIssuedAt = Date()
|
||||||
let defaultExpiresIn = 60
|
let defaultExpiresIn = 60
|
||||||
|
|
||||||
var token: String
|
var token: String?
|
||||||
|
var accessToken: String?
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
var issuedAt: Date?
|
var issuedAt: Date?
|
||||||
|
|
||||||
@@ -65,7 +66,13 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
|
||||||
|
guard response.token != nil || response.accessToken != nil else {
|
||||||
|
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenExpiresAt: Date {
|
var tokenExpiresAt: Date {
|
||||||
@@ -83,7 +90,7 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func header() -> (String, String) {
|
func header() -> (String, String) {
|
||||||
("Authorization", "Bearer \(token)")
|
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValid() -> Bool {
|
func isValid() -> Bool {
|
||||||
@@ -92,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
|
||||||
|
|
||||||
@@ -98,13 +98,37 @@ struct Darwin: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
|
return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
|
||||||
|
} else {
|
||||||
|
return [VZUSBKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 starting with macOS Ventura
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
// macOS Monterey will continue using a USB device == .darwin
|
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
} else {
|
} else {
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
|
if #available(macOS 13, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
|
[VZUSBKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,11 @@ protocol Platform: Codable {
|
|||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration]
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocol PlatformSuspendable: Platform {
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||||
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||||
|
}
|
||||||
|
|||||||
+12
-4
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Get.self,
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
|
Logout.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
@@ -57,6 +58,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);
|
||||||
@@ -76,10 +82,12 @@ struct Root: AsyncParsableCommand {
|
|||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
// Run garbage-collection before each command (shouldn't take too long)
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
do {
|
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||||
try Config().gc()
|
do {
|
||||||
} catch {
|
try Config().gc()
|
||||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
|
|||||||
+70
-28
@@ -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,23 +300,32 @@ 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 = [VZUSBKeyboardConfiguration()]
|
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()
|
configuration.networkDevices = network.attachments().map {
|
||||||
vio.attachment = network.attachment()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.attachment = $0
|
||||||
configuration.networkDevices = [vio]
|
vio.macAddress = vmConfig.macAddress
|
||||||
|
return vio
|
||||||
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||||
@@ -307,7 +333,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 +343,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
@@ -13,6 +14,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 +31,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 {
|
||||||
@@ -37,6 +60,17 @@ struct VMDirectory: Prunable {
|
|||||||
return VMDirectory(baseURL: tmpDir)
|
return VMDirectory(baseURL: tmpDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Create tmp directory with hashing
|
||||||
|
static func temporaryDeterministic(key: String) throws -> VMDirectory {
|
||||||
|
let keyData = Data(key.utf8)
|
||||||
|
let hash = Insecure.MD5.hash(data: keyData)
|
||||||
|
// Convert hash to string
|
||||||
|
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
|
||||||
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
||||||
|
return VMDirectory(baseURL: tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
var initialized: Bool {
|
var initialized: Bool {
|
||||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||||
@@ -70,6 +104,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 +120,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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||||
|
}
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
|
|
||||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
@@ -140,6 +144,12 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: Digest.hash(manifestData)))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
|
if exists(name) && exists(digestName) && linked(from: name, to: digestName) {
|
||||||
|
// optimistically check if we need to do anything at all before locking
|
||||||
|
defaultLogger.appendNewLine("\(digestName) image is already cached and linked!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||||
@@ -160,7 +170,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
@@ -168,36 +178,13 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
// Try to reclaim some cache space if we know the VM size in advance
|
// Try to reclaim some cache space if we know the VM size in advance
|
||||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
|
||||||
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
|
||||||
let capacityAvailable = attrs.volumeAvailableCapacity!
|
|
||||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
|
||||||
|
|
||||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
|
||||||
SentrySDK.capture(message: "Zero capacity") { scope in
|
|
||||||
scope.setLevel(.warning)
|
|
||||||
|
|
||||||
scope.setContext(value: [
|
|
||||||
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
|
||||||
"volumeAvailableCapacityKey": capacityAvailable,
|
|
||||||
], key: "Attributes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||||
// Let's validate to avoid unnecessary pruning.
|
|
||||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
|
||||||
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
|
||||||
transaction.setData(value: name, key: "name")
|
|
||||||
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
|
||||||
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
|
||||||
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
|
||||||
defer { transaction.finish() }
|
|
||||||
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
@@ -222,6 +209,15 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||||
|
do {
|
||||||
|
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
|
||||||
|
return resolvedFrom == vmURL(to).path
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
||||||
try FileManager.default.removeItem(at: vmURL(from))
|
try FileManager.default.removeItem(at: vmURL(from))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
@@ -13,7 +13,7 @@ categories:
|
|||||||
|
|
||||||
**TLDR:** We are transitioning Tart's licensing from AGPL-3.0 to [Fair Source 100](https://fair.io/). This change will
|
**TLDR:** We are transitioning Tart's licensing from AGPL-3.0 to [Fair Source 100](https://fair.io/). This change will
|
||||||
permit unlimited installations on personal computers, but organizations that exceed a certain number of server
|
permit unlimited installations on personal computers, but organizations that exceed a certain number of server
|
||||||
installations utilizing 100 CPU cores will be required to obtain a paid sponsorship.
|
installations utilizing 100 CPU cores will be required to obtain a paid license.
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
@@ -55,15 +55,15 @@ of Tart virtual machines on a cluster of Apple Silicon servers. Concurrently, we
|
|||||||
which will establish a stable API and offer long-term support under a new Fair Source 100 license.
|
which will establish a stable API and offer long-term support under a new Fair Source 100 license.
|
||||||
|
|
||||||
The Fair Source 100 license for Tart means that once a certain threshold of server installations utilizing 100 CPU cores
|
The Fair Source 100 license for Tart means that once a certain threshold of server installations utilizing 100 CPU cores
|
||||||
is exceeded, a paid sponsorship will be required. A "server installation" refers to the installation of Tart on a physical
|
is exceeded, a paid license will be required. A "server installation" refers to the installation of Tart on a physical
|
||||||
device without a physical display connected. For example, a Mac Mini with a HDMI Dummy Plug is considered a server,
|
device without a physical display connected. For example, a Mac Mini with a HDMI Dummy Plug is considered a server,
|
||||||
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
but a Mac Mini on a desk with a connected physical display is considered a personal computer. **Usage on personal computers
|
||||||
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
and before reaching the 100 CPU cores limit is royalty-free and does not have the viral properties of AGPL.**
|
||||||
|
|
||||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Sponsorship](/licensing#sponsorships),
|
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
||||||
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Sponsorship](/licensing#sponsorships)
|
which costs \$1000 per month. Upon reaching a limit of 500 CPU cores, a [Platinum Tier License](/licensing#license-tiers)
|
||||||
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Sponsorship](/licensing#sponsorships)
|
(\$5000 per month) will be required, and for organizations that exceed 5000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||||
(\$1 per core per month) will be necessary. **All sponsorships will include priority feature development and SLAs on support with urgent issues.**
|
(\$1 per core per month) will be necessary. **All paid license tiers will include priority feature development and SLAs on support with urgent issues.**
|
||||||
|
|
||||||
## Have we considered alternatives?
|
## Have we considered alternatives?
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
+49
-15
@@ -6,35 +6,69 @@ hide:
|
|||||||
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and [Orchard Orchestration](https://github.com/cirruslabs/orchard)
|
Both [Tart Virtualization](https://github.com/cirruslabs/tart) and [Orchard Orchestration](https://github.com/cirruslabs/orchard)
|
||||||
are licensed under [Fair Source License](https://fair.io/). Usage on personal computers including personal workstations is royalty-free,
|
are licensed under [Fair Source License](https://fair.io/). Usage on personal computers including personal workstations is royalty-free,
|
||||||
but organizations that exceed a certain number of server installations (100 CPU cores for Tart and/or 4 hosts for Orchard)
|
but organizations that exceed a certain number of server installations (100 CPU cores for Tart and/or 4 hosts for Orchard)
|
||||||
will be required to obtain a paid sponsorship.
|
will be required to obtain a paid license.
|
||||||
|
|
||||||
??? note "Performance and Efficiency Cores"
|
??? note "Host CPU Core usage"
|
||||||
The virtual CPU cores in Tart VMs do not differentiate between the high-performance and high-efficient cores
|
The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
|
||||||
of the host CPU. Instead, Tart VMs automatically alternate between these types of cores depending on the workload
|
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
|
||||||
being executed within the virtual machines. As a result, both performance and energy-efficient cores of the host CPU
|
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
|
||||||
are treated equally in terms of licensing.
|
|
||||||
|
|
||||||
# Sponsorships
|
# License Tiers
|
||||||
|
|
||||||
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Sponsorship, which costs \$1000 per month.
|
## Free Tier
|
||||||
Upon reaching a limit of 500 CPU cores, a Platinum Sponsorship (\$5000 per month) will be required, and for organizations
|
|
||||||
that exceed 5000 CPU cores, a custom Diamond Sponsorship (\$1 per core per month) will be necessary.
|
|
||||||
|
|
||||||
If your organization is interested in purchasing one of the sponsorships, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
|
||||||
You can see a template of a sponsorship subscription agreement [here](assets/TartSponsorshipSubscriptionTemplate.pdf).
|
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
|
||||||
|
|
||||||
|
Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit for Orchard.
|
||||||
|
|
||||||
|
??? info "Usage Scenarios Examples"
|
||||||
|
|
||||||
|
Here are a few examples that fit into the free tier:
|
||||||
|
|
||||||
|
- Using Tart on 12 Mac Minis with 8 CPUs each running up to 24 VMs in parallel.
|
||||||
|
- Creating an Orchard cluster of 4 Mac Studio workers with 24 CPUs each.
|
||||||
|
|
||||||
|
Here are a few examples that do not fit into the free tier:
|
||||||
|
|
||||||
|
- Using Tart on 13 Mac Minis with 8 CPUs each.
|
||||||
|
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
|
||||||
|
|
||||||
|
## Gold Tier
|
||||||
|
|
||||||
|
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
|
||||||
|
|
||||||
|
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
|
||||||
|
|
||||||
|
## Platinum Tier
|
||||||
|
|
||||||
|
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
|
||||||
|
|
||||||
|
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
|
||||||
|
|
||||||
|
## Diamond Tier
|
||||||
|
|
||||||
|
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
|
||||||
|
|
||||||
|
# Get the license
|
||||||
|
|
||||||
|
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
|
||||||
|
|
||||||
|
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
|
||||||
|
|
||||||
# General Support
|
# General Support
|
||||||
|
|
||||||
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
The best way to ask general questions about particular use cases is to email our support team at [support@cirruslabs.org](mailto:support@cirruslabs.org).
|
||||||
Our support team is trying our best to respond ASAP, but there is no guarantee on a response time unless your organization
|
Our support team is trying our best to respond ASAP, but there is no guarantee on a response time unless your organization
|
||||||
has a sponsorship subscription which includes [Priority Support](#priority-support).
|
has a paid license subscription which includes [Priority Support](#priority-support).
|
||||||
|
|
||||||
If you have a feature request or noticed lack of some documentation please feel free to [create a GitHub issue](https://github.com/cirruslabs/tart/issues/new).
|
If you have a feature request or noticed lack of some documentation please feel free to [create a GitHub issue](https://github.com/cirruslabs/tart/issues/new).
|
||||||
Our support team will answer it by replying to the issue or by updating the documentation.
|
Our support team will answer it by replying to the issue or by updating the documentation.
|
||||||
|
|
||||||
# Priority Support
|
# Priority Support
|
||||||
|
|
||||||
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid sponsorships .
|
In addition to the general support we provide a *Priority Support* with guaranteed response times included in all the paid license tiers.
|
||||||
|
|
||||||
| Severity | Support Impact | First Response Time SLA | Hours | How to Submit |
|
| Severity | Support Impact | First Response Time SLA | Hours | How to Submit |
|
||||||
|----------|-----------------------------------------------------------------------------------------------|-------------------------|-------|--------------------------------------------------------------------------------------------------|
|
|----------|-----------------------------------------------------------------------------------------------|-------------------------|-------|--------------------------------------------------------------------------------------------------|
|
||||||
@@ -57,5 +91,5 @@ In addition to the general support we provide a *Priority Support* with guarante
|
|||||||
Information, an enhancement, or documentation clarification is requested, but there is no impact on the operation of Tart and/or Orchard.
|
Information, an enhancement, or documentation clarification is requested, but there is no impact on the operation of Tart and/or Orchard.
|
||||||
|
|
||||||
!!! info "How to submit a priority or an urgent issue"
|
!!! info "How to submit a priority or an urgent issue"
|
||||||
Once your organization [signs the Sponsorship Subscription contract](#sponsorships), members of your organization
|
Once your organization [obtains a license](#license-tiers), members of your organization
|
||||||
will get access to separate support emails specified in your subscription contract.
|
will get access to separate support emails specified in your subscription contract.
|
||||||
|
|||||||
+22
-1
@@ -33,9 +33,19 @@ tart run ventura-base
|
|||||||
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh admin@$(tart ip macos-ventura-base)
|
ssh admin@$(tart ip ventura-base)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
!!! tip "Running scripts inside Tart virtual machines"
|
||||||
|
We recommend using [Cirrus CLI](integrations/cirrus-cli.md) to run scripts and/or retrieve artifacts
|
||||||
|
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install sshpass
|
||||||
|
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) "uname -a"
|
||||||
|
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Mounting directories
|
## Mounting directories
|
||||||
|
|
||||||
To mount a directory, run the VM with the `--dir` argument:
|
To mount a directory, run the VM with the `--dir` argument:
|
||||||
@@ -70,6 +80,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.2/dist/dotlottie-player.js"></script>
|
||||||
|
|
||||||
<!-- landing page for landing page -->
|
<!-- landing page for landing page -->
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
|
|||||||
Reference in New Issue
Block a user