Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd46033812 | ||
|
|
c655288de7 | ||
|
|
204002f776 | ||
|
|
a0ae2f4e66 | ||
|
|
7c386e3466 | ||
|
|
dbbd716214 | ||
|
|
13d5ddb4a4 | ||
|
|
626316a4cd | ||
|
|
fbe35302c2 | ||
|
|
e1353f4540 | ||
|
|
985db24474 | ||
|
|
1d01bf63fb | ||
|
|
3ff3850da2 | ||
|
|
c6e8d0bfd7 | ||
|
|
755aad4d7c | ||
|
|
9f38441a42 | ||
|
|
3d46c4e6c2 | ||
|
|
e59221f6a0 | ||
|
|
c6e99345cd | ||
|
|
8bc2e99f63 | ||
|
|
f36f86b61c | ||
|
|
79084555f6 | ||
|
|
896d03ce0b | ||
|
|
99c91cbf87 | ||
|
|
da8afa1348 | ||
|
|
97b7ffef52 | ||
|
|
13e7794bfc | ||
|
|
b7b3b702ac | ||
|
|
560dba79e4 | ||
|
|
2b33b8f9e6 | ||
|
|
d8b010c79c | ||
|
|
5cd83c38cd | ||
|
|
1a3b862631 | ||
|
|
ae2d59e5c2 | ||
|
|
7eac45702b | ||
|
|
e06d89f95d | ||
|
|
0602d6e0e1 | ||
|
|
ac5d0baa0c | ||
|
|
ee27cc57bb | ||
|
|
a9e2a19015 | ||
|
|
89ff5f6b65 | ||
|
|
6bf39e73d0 | ||
|
|
0b693f6bc9 | ||
|
|
99bbd838a1 | ||
|
|
5c7743b7cd | ||
|
|
a40e104c03 | ||
|
|
e2d6c13ed0 | ||
|
|
3f17884ac2 | ||
|
|
7dcebf9c04 | ||
|
|
f6c56ed8eb | ||
|
|
a48f4d4ec9 | ||
|
|
9bb71a4051 | ||
|
|
18d462dd3d | ||
|
|
cd6a97f842 | ||
|
|
3e4bc73ff0 | ||
|
|
89fff42d0a | ||
|
|
7bb50b5e4b | ||
|
|
b5cbf67ee6 | ||
|
|
090a8232fc | ||
|
|
306ace792c | ||
|
|
96f6f94fa7 | ||
|
|
fbc481250d | ||
|
|
35538c2c5d | ||
|
|
4c33064916 | ||
|
|
1a267d4a39 | ||
|
|
36c54d95cb | ||
|
|
1d8bfafde5 | ||
|
|
537f0ae5db | ||
|
|
02f1ff5238 | ||
|
|
9c9bcd586e | ||
|
|
60f0eac7a8 | ||
|
|
35377a3475 | ||
|
|
dda4e91a91 | ||
|
|
ac5f794e6d | ||
|
|
5bcbc77249 | ||
|
|
bf03873c8d | ||
|
|
bad37b129c | ||
|
|
0f47cca746 | ||
|
|
d70eca4484 | ||
|
|
25887b075f | ||
|
|
8c011623be | ||
|
|
2dccdfb306 | ||
|
|
aca768a838 | ||
|
|
68b3557747 | ||
|
|
b2c923f2fe | ||
|
|
c75009e46f | ||
|
|
43e74ab769 | ||
|
|
1338864ed6 | ||
|
|
70040b633c | ||
|
|
f4bc02d175 | ||
|
|
6c24aa639a | ||
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 | ||
|
|
71d03226fe | ||
|
|
36dab9878d | ||
|
|
f634002813 | ||
|
|
8e79669afb | ||
|
|
2da8bc0fb5 | ||
|
|
2d984ba194 | ||
|
|
50ce44c3eb | ||
|
|
c9e49ceb39 | ||
|
|
6df50e55d8 | ||
|
|
1fd710d00d | ||
|
|
4f6c7e79e1 | ||
|
|
1afb43e85b | ||
|
|
954cac3bee | ||
|
|
3ff4fc34c6 | ||
|
|
e118b42b1f | ||
|
|
f823190039 | ||
|
|
27cadc3f3b | ||
|
|
d4d3852745 | ||
|
|
4bb248e7b4 | ||
|
|
f45551cbf0 | ||
|
|
f68297097e | ||
|
|
637a2387e7 | ||
|
|
050d6a6ff1 | ||
|
|
5eddd1ce41 | ||
|
|
35f5b30bc4 | ||
|
|
8b27fea745 | ||
|
|
e00f62c95a | ||
|
|
d90893e9a0 | ||
|
|
feb733a7c0 | ||
|
|
545b6fcd94 | ||
|
|
c750d63ac9 | ||
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 |
@@ -7,6 +7,8 @@ export VERSION="${CIRRUS_TAG:-0}"
|
|||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||||
|
cp Resources/AppIcon.png .ci/pkg/AppIcon.png
|
||||||
|
cp Resources/Info.plist .ci/pkg/Info.plist
|
||||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||||
--scripts .ci/pkg/scripts \
|
--scripts .ci/pkg/scripts \
|
||||||
--install-location "/Library/Application Support/Tart" \
|
--install-location "/Library/Application Support/Tart" \
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# fix structure
|
# fix structure
|
||||||
mkdir -p "$2/tart.app/Contents/MacOS"
|
mkdir -p "$2/tart.app/Contents/MacOS" "$2/tart.app/Resources"
|
||||||
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
||||||
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
||||||
|
mv "$2/AppIcon.png" "$2/tart.app/Resources/AppIcon.png"
|
||||||
|
mv "$2/Info.plist" "$2/tart.app/Contents/Info.plist"
|
||||||
|
|
||||||
echo "#!/bin/sh" > /usr/local/bin/tart
|
echo "#!/bin/sh" > /usr/local/bin/tart
|
||||||
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
use_compute_credits: true
|
use_compute_credits: true
|
||||||
|
|
||||||
env:
|
|
||||||
XCODE_TAG: 15-beta-2
|
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Sonoma
|
||||||
alias: test
|
alias: test
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: dev-mini
|
name: dev-mini
|
||||||
|
resources:
|
||||||
|
tart-vms: 1
|
||||||
test_script:
|
test_script:
|
||||||
- swift test
|
- swift test
|
||||||
integration_test_script:
|
integration_test_script:
|
||||||
@@ -28,12 +26,19 @@ task:
|
|||||||
path: "integration-tests/pytest-junit.xml"
|
path: "integration-tests/pytest-junit.xml"
|
||||||
format: junit
|
format: junit
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Markdown Lint
|
||||||
|
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
|
||||||
|
container:
|
||||||
|
image: node:latest
|
||||||
|
install_script: npm install -g markdownlint-cli
|
||||||
|
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Lint
|
name: Lint
|
||||||
alias: lint
|
alias: lint
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
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:
|
||||||
@@ -43,15 +48,18 @@ task:
|
|||||||
|
|
||||||
task:
|
task:
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
name: Build
|
env:
|
||||||
|
matrix:
|
||||||
|
BUILD_ARCH: arm64
|
||||||
|
BUILD_ARCH: x86_64
|
||||||
|
name: Build ($BUILD_ARCH)
|
||||||
alias: build
|
alias: build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
build_script: swift build --product tart
|
build_script: swift build --arch $BUILD_ARCH --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/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
path: .build/debug/tart
|
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
|
|
||||||
task:
|
task:
|
||||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||||
@@ -59,9 +67,8 @@ task:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- lint
|
- lint
|
||||||
- build
|
- build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -77,7 +84,7 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
- brew install go goreleaser/tap/goreleaser-pro
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
@@ -95,9 +102,8 @@ task:
|
|||||||
- lint
|
- lint
|
||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
use_compute_credits: $CIRRUS_USER_COLLABORATOR == 'true'
|
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:$XCODE_TAG
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -142,7 +148,6 @@ 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!]
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ dist/
|
|||||||
|
|
||||||
# mkdocs
|
# mkdocs
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# mkdocs-material
|
||||||
|
site
|
||||||
|
|||||||
@@ -3,27 +3,35 @@ project_name: tart
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- .ci/set-version.sh
|
- .ci/set-version.sh
|
||||||
- swift build -c release --product tart
|
- swift build --arch x86_64 -c release --product tart
|
||||||
|
- swift build --arch arm64 -c release --product tart
|
||||||
- gon gon.hcl
|
- gon gon.hcl
|
||||||
- mkdir -p tart.app/Contents/MacOS
|
|
||||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- builder: prebuilt
|
- id: tart
|
||||||
|
builder: prebuilt
|
||||||
|
goamd64: [v1]
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
goarch:
|
goarch:
|
||||||
- arm64
|
- arm64
|
||||||
|
- amd64
|
||||||
binary: tart.app/Contents/MacOS/tart
|
binary: tart.app/Contents/MacOS/tart
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: tart.app/Contents/MacOS/tart
|
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "{{ .ProjectName }}"
|
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||||
files:
|
files:
|
||||||
- src: Resources/embedded.provisionprofile
|
- src: Resources/embedded.provisionprofile
|
||||||
dst: tart.app/Contents
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
|
- src: Resources/Info.plist
|
||||||
|
dst: tart.app/Contents
|
||||||
|
strip_parent: true
|
||||||
|
- src: Resources/AppIcon.png
|
||||||
|
dst: tart.app/Contents/Resources
|
||||||
|
strip_parent: true
|
||||||
- LICENSE
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -31,24 +39,19 @@ release:
|
|||||||
|
|
||||||
brews:
|
brews:
|
||||||
- name: tart
|
- name: tart
|
||||||
tap:
|
repository:
|
||||||
owner: cirruslabs
|
owner: cirruslabs
|
||||||
name: homebrew-cli
|
name: homebrew-cli
|
||||||
caveats: See the GitHub repository for more information
|
caveats: See the GitHub repository for more information
|
||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
license: "Fair Source"
|
license: "Fair Source"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS and Linux VMs on Apple Hardware
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "cirruslabs/cli/softnet"
|
||||||
install: |
|
install: |
|
||||||
libexec.install Dir["*"]
|
libexec.install Dir["*"]
|
||||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||||
|
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||||
custom_block: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :ventura
|
||||||
|
|
||||||
on_macos do
|
|
||||||
unless Hardware::CPU.arm?
|
|
||||||
odie "Tart only works on Apple Silicon!"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"originHash" : "6d48639bc0ea02002de0b4f38fe3fce0ddc9d174f2e56180c2ffcbedb7391ef8",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
@@ -18,13 +19,22 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "semaphore",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/Semaphore",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f1c4a0acabeb591068dea6cffdd39660b86dec28",
|
||||||
|
"version" : "0.0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sentry-cocoa",
|
"identity" : "sentry-cocoa",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
"revision" : "ef4fec9dfb8dd5027b09a4a5c9362feafd118e1a",
|
||||||
"version" : "8.8.0"
|
"version" : "8.24.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,8 +42,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-algorithms",
|
"location" : "https://github.com/apple/swift-algorithms",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||||
"version" : "1.0.0"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,8 +51,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
"revision" : "46989693916f56d1186bd59ac15124caef896560",
|
||||||
"version" : "1.1.2"
|
"version" : "1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +69,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-atomics.git",
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||||
"version" : "1.0.2"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,6 +82,15 @@
|
|||||||
"version" : "1.0.3"
|
"version" : "1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-log",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "e97a6fcb1ab07462881ac165fdbb37f067e205d5",
|
||||||
|
"version" : "1.5.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-numerics",
|
"identity" : "swift-numerics",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -81,13 +100,22 @@
|
|||||||
"version" : "1.0.2"
|
"version" : "1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-retry",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9f133487ffc2ab4539688c29efe57bb1ba31d7b0",
|
||||||
|
"version" : "0.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-sysctl",
|
"identity" : "swift-sysctl",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||||
"version" : "1.6.0"
|
"version" : "1.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,8 +123,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/malcommac/SwiftDate",
|
"location" : "https://github.com/malcommac/SwiftDate",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||||
"version" : "6.3.1"
|
"version" : "7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -104,8 +132,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
"revision" : "9df3b01f477163b33d5e63c5e2e5b9f946a49c56",
|
||||||
"version" : "0.50.6"
|
"version" : "0.53.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftradix",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||||
|
"version" : "1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,5 +155,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
// swift-tools-version:5.7
|
// swift-tools-version:5.10
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Tart",
|
name: "Tart",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v12)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "tart", targets: ["tart"])
|
.executable(name: "tart", targets: ["tart"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||||
.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.2.0")),
|
||||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.24.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.8.0"),
|
||||||
|
.package(url: "https://github.com/orchetect/SwiftRadix", from: "1.3.1"),
|
||||||
|
.package(url: "https://github.com/groue/Semaphore", from: "0.0.8"),
|
||||||
|
.package(url: "https://github.com/fumoboy007/swift-retry", from: "0.2.3"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
@@ -34,6 +37,9 @@ let package = Package(
|
|||||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
.product(name: "TextTable", package: "TextTable"),
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||||
|
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||||
|
.product(name: "Semaphore", package: "Semaphore"),
|
||||||
|
.product(name: "DMRetry", package: "swift-retry"),
|
||||||
], exclude: [
|
], exclude: [
|
||||||
"OCI/Reference/Makefile",
|
"OCI/Reference/Makefile",
|
||||||
"OCI/Reference/Reference.g4",
|
"OCI/Reference/Reference.g4",
|
||||||
|
|||||||
@@ -6,23 +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://cirrus-runners.app/)
|
||||||
|
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">
|
<p align="center">
|
||||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||||
</a>
|
|
||||||
<a href="https://codemagic.io/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
|
||||||
</a>
|
|
||||||
<a href="https://testingbot.com/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
Many companies are using Tart in their internal setups. Here are a few of them:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://ahrefs.com/" target=_blank>
|
<a href="https://ahrefs.com/" target=_blank>
|
||||||
@@ -34,6 +29,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://mullvad.net/" target=_blank>
|
<a href="https://mullvad.net/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://shape.dk/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/shape.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>
|
||||||
@@ -43,6 +41,9 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://transloadit.com/" target=_blank>
|
<a href="https://transloadit.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://uphold.com/" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
|
||||||
|
</a>
|
||||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
@@ -50,14 +51,20 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
|
|
||||||
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://aws.amazon.com/marketplace/pp/prodview-qczco34wlkdws?utm_source=github&utm_medium=referral" target=_blank>
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/AWSMarkeplaceLogo.png" height="90"/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||||
tart run ventura-base
|
tart run sonoma-base
|
||||||
```
|
```
|
||||||
|
|
||||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>tart</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>org.cirruslabs.tart</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>tart</string>
|
||||||
|
<key>LSBackgroundOnly</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleIconFiles</key>
|
||||||
|
<array>
|
||||||
|
<string>AppIcon.png</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,9 +3,22 @@ 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.
|
||||||
|
|
||||||
@Argument(help: "source VM name")
|
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", completion: .custom(completeMachines))
|
||||||
var sourceName: String
|
var sourceName: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -14,10 +27,17 @@ struct Clone: 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
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -27,7 +47,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
@@ -43,7 +63,7 @@ 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"
|
&& 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)
|
||||||
@@ -53,7 +73,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
// 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.
|
// 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.
|
// 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)
|
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Create: AsyncParsableCommand {
|
struct Create: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||||
@@ -15,13 +16,18 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Flag(help: "create a Linux VM")
|
@Flag(help: "create a Linux VM")
|
||||||
var linux: Bool = false
|
var linux: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||||
var diskSize: UInt16 = 50
|
var diskSize: UInt16 = 50
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil && !linux {
|
if fromIPSW == nil && !linux {
|
||||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||||
}
|
}
|
||||||
|
#if arch(x86_64)
|
||||||
|
if fromIPSW != nil {
|
||||||
|
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -32,26 +38,32 @@ struct Create: AsyncParsableCommand {
|
|||||||
try tmpVMDirLock.lock()
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
if let fromIPSW = fromIPSW {
|
#if arch(arm64)
|
||||||
let ipswURL: URL
|
if let fromIPSW = fromIPSW {
|
||||||
|
let ipswURL: URL
|
||||||
|
|
||||||
if fromIPSW == "latest" {
|
if fromIPSW == "latest" {
|
||||||
ipswURL = try await VM.latestIPSWURL()
|
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
|
||||||
ipswURL = URL(string: fromIPSW)!
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
} else {
|
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipswURL = image.url
|
||||||
|
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||||
|
ipswURL = URL(string: fromIPSW)!
|
||||||
|
} else {
|
||||||
|
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if linux {
|
if linux {
|
||||||
if #available(macOS 13, *) {
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SwiftUI
|
|||||||
struct Delete: AsyncParsableCommand {
|
struct Delete: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
var name: [String]
|
var name: [String]
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||||
|
|
||||||
@Argument(help: "Source VM name.")
|
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "Path to the destination file.")
|
@Argument(help: "Path to the destination file.")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import SystemConfiguration
|
||||||
|
|
||||||
|
struct FQN: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
if var remoteName = try? RemoteName(name) {
|
||||||
|
let digest = try VMStorageOCI().digest(remoteName)
|
||||||
|
|
||||||
|
remoteName.reference = Reference(digest: digest)
|
||||||
|
|
||||||
|
print(remoteName)
|
||||||
|
} else {
|
||||||
|
print(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let OS: OS
|
||||||
let CPU: Int
|
let CPU: Int
|
||||||
let Memory: UInt64
|
let Memory: UInt64
|
||||||
let Disk: Int
|
let Disk: Int
|
||||||
|
let Size: String
|
||||||
let Display: String
|
let Display: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
@@ -13,7 +15,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
struct Get: AsyncParsableCommand {
|
struct Get: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name.")
|
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Output format: text or json")
|
@Option(help: "Output format: text or json")
|
||||||
@@ -22,11 +24,9 @@ struct Get: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let diskSizeInGb = try vmDir.sizeGB()
|
|
||||||
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(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
|||||||
struct IP: AsyncParsableCommand {
|
struct IP: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||||
@@ -61,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
case .dhcp:
|
case .dhcp:
|
||||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SwiftUI
|
|||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
let Source: String
|
let Source: String
|
||||||
let Name: String
|
let Name: String
|
||||||
|
let Disk: Int
|
||||||
let Size: Int
|
let Size: Int
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
let State: String
|
let State: String
|
||||||
@@ -37,13 +38,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(), State: vmDir.state())
|
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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(), State: vmDir.state())
|
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() - 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.allocatedSizeBytes())
|
||||||
|
|
||||||
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)
|
||||||
@@ -78,6 +105,10 @@ struct Prune: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||||
}
|
}
|
||||||
@@ -127,7 +158,7 @@ struct Prune: AsyncParsableCommand {
|
|||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
// Does it even make sense to start?
|
// Does it even make sense to start?
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||||
if cacheUsedBytes < reclaimBytes {
|
if cacheUsedBytes < reclaimBytes {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,9 +177,9 @@ struct Prune: AsyncParsableCommand {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
|
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||||
|
|
||||||
try prunable.delete()
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,6 +20,15 @@ struct Pull: 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
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
// see https://github.com/cirruslabs/tart/issues/36
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
@@ -25,6 +43,6 @@ struct Pull: AsyncParsableCommand {
|
|||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||||
|
|
||||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Compression
|
|||||||
struct Push: AsyncParsableCommand {
|
struct Push: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||||
|
|
||||||
@Argument(help: "local or remote VM name")
|
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
|
||||||
var localName: String
|
var localName: String
|
||||||
|
|
||||||
@Argument(help: "remote VM name(s)")
|
@Argument(help: "remote VM name(s)")
|
||||||
@@ -23,6 +23,9 @@ struct Push: AsyncParsableCommand {
|
|||||||
"""))
|
"""))
|
||||||
var chunkSize: Int = 0
|
var chunkSize: Int = 0
|
||||||
|
|
||||||
|
@Option(help: .hidden)
|
||||||
|
var diskFormat: String = "v2"
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||||
var populateCache: Bool = false
|
var populateCache: Bool = false
|
||||||
@@ -69,7 +72,8 @@ struct Push: AsyncParsableCommand {
|
|||||||
pushedRemoteName = try await localVMDir.pushToRegistry(
|
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||||
registry: registry,
|
registry: registry,
|
||||||
references: references,
|
references: references,
|
||||||
chunkSizeMb: chunkSize
|
chunkSizeMb: chunkSize,
|
||||||
|
diskFormat: diskFormat
|
||||||
)
|
)
|
||||||
// Populate the local cache (if requested)
|
// Populate the local cache (if requested)
|
||||||
if populateCache {
|
if populateCache {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Rename: AsyncParsableCommand {
|
struct Rename: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -20,11 +20,11 @@ struct Rename: AsyncParsableCommand {
|
|||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
if !localStorage.exists(name) {
|
if !localStorage.exists(name) {
|
||||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if localStorage.exists(newName) {
|
if localStorage.exists(newName) {
|
||||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
|
||||||
}
|
}
|
||||||
|
|
||||||
try localStorage.rename(name, newName)
|
try localStorage.rename(name, newName)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Cocoa
|
import Cocoa
|
||||||
|
import Darwin
|
||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import System
|
||||||
|
|
||||||
var vm: VM?
|
var vm: VM?
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ struct IPNotFound: Error {
|
|||||||
struct Run: AsyncParsableCommand {
|
struct Run: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
static var configuration = CommandConfiguration(abstract: "Run a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
@@ -32,58 +34,83 @@ struct Run: AsyncParsableCommand {
|
|||||||
))
|
))
|
||||||
var serialPath: String?
|
var serialPath: String?
|
||||||
|
|
||||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
@Flag(help: ArgumentHelp("Force open a UI window, even when VNC is enabled.", visibility: .private))
|
||||||
var graphics: Bool = false
|
var graphics: Bool = false
|
||||||
|
|
||||||
@Flag(help: "Boot into recovery mode")
|
@Flag(help: "Disable audio pass-through to host.")
|
||||||
|
var noAudio: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp(
|
||||||
|
"Disable clipboard sharing between host and guest.",
|
||||||
|
discussion: "Only works with Linux-based guest operating systems."))
|
||||||
|
var noClipboard: Bool = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
@Flag(help: "Boot into recovery mode")
|
||||||
|
#endif
|
||||||
var recovery: Bool = false
|
var recovery: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
#if arch(arm64)
|
||||||
"Use screen sharing instead of the built-in UI.",
|
@Flag(help: ArgumentHelp(
|
||||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
"Use screen sharing instead of the built-in UI.",
|
||||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||||
|
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||||
|
#endif
|
||||||
var vnc: Bool = false
|
var vnc: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
#if arch(arm64)
|
||||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
@Flag(help: ArgumentHelp(
|
||||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
"Use Virtualization.Framework's VNC server instead of the built-in UI.",
|
||||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||||
|
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||||
|
#endif
|
||||||
var vncExperimental: Bool = false
|
var vncExperimental: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\" --disk=\"/dev/disk0\" --disk=\"nbd://localhost:10809/myDisk\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
|
Can be either a disk image file, a block device like a local SSD on AWS EC2 Mac instances or a Network Block Device (NBD).
|
||||||
|
|
||||||
Learn how to create a disk image using Disk Utility here:
|
Learn how to create a disk image using Disk Utility here:
|
||||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||||
|
|
||||||
|
To work with block devices, the easiest way is to modify their permissions (e.g. by using "sudo chown $USER /dev/diskX") or to run the Tart binary as root, which affects locating Tart VMs.
|
||||||
|
|
||||||
|
To work around this pass TART_HOME explicitly:
|
||||||
|
|
||||||
|
sudo TART_HOME="$HOME/.tart" tart run sonoma --disk=/dev/disk0
|
||||||
""", valueName: "path[:ro]"))
|
""", valueName: "path[:ro]"))
|
||||||
var disk: [String] = []
|
var disk: [String] = []
|
||||||
|
|
||||||
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
#if arch(arm64)
|
||||||
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
|
||||||
discussion: """
|
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
|
||||||
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
discussion: """
|
||||||
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
|
||||||
|
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
|
||||||
|
|
||||||
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
Note that you also have to configure Rosetta in the guest Linux VM by following the
|
||||||
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
steps from "Mount the Shared Directory and Register Rosetta" section here:
|
||||||
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
|
||||||
""",
|
""",
|
||||||
valueName: "tag"
|
valueName: "tag"
|
||||||
))
|
))
|
||||||
|
#endif
|
||||||
var rosettaTag: String?
|
var rosettaTag: String?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """
|
||||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")
|
Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too.
|
||||||
""", discussion: """
|
|
||||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
|
||||||
A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
|
||||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
|
||||||
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
|
||||||
|
|
||||||
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\"
|
Options are comma-separated and are as follows:
|
||||||
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.
|
* ro — mount this directory share in read-only mode instead of the default read-write (e.g. --dir=\"~/src/sources:ro\")
|
||||||
""", valueName: "[name:]path[:ro]"))
|
|
||||||
|
* tag=<TAG> — by default, the \"com.apple.virtio-fs.automount\" mount tag is used for all directory shares. On macOS, this causes the directories to be automatically mounted to "/Volumes/My Shared Files" directory. On Linux, you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||||
|
|
||||||
|
Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux.
|
||||||
|
|
||||||
|
In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively.
|
||||||
|
""", valueName: "[name:]path[:options]"))
|
||||||
var dir: [String] = []
|
var dir: [String] = []
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
@@ -91,33 +118,73 @@ 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
|
||||||
|
|
||||||
@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\"."))
|
@Option(help: ArgumentHelp("Comma-separated list of CIDRs to allow the traffic to when using Softnet isolation\n(e.g. --net-softnet-allow=192.168.0.0/24)", valueName: "comma-separated CIDRs"))
|
||||||
|
var netSoftnetAllow: String?
|
||||||
|
|
||||||
|
@Flag(help: ArgumentHelp("Restrict network access to the host-only network"))
|
||||||
|
var netHost: Bool = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
@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\"."))
|
||||||
|
#endif
|
||||||
var suspendable: Bool = false
|
var suspendable: Bool = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
@Flag(help: ArgumentHelp("Whether system hot keys should be sent to the guest instead of the host",
|
||||||
|
discussion: "If enabled then system hot keys like Cmd+Tab will be sent to the guest instead of the host."))
|
||||||
|
#endif
|
||||||
|
var captureSystemKeys: Bool = false
|
||||||
|
|
||||||
mutating func validate() throws {
|
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 {
|
// check that not more than one network option is specified
|
||||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
var netFlags = 0
|
||||||
|
if netBridged.count > 0 { netFlags += 1 }
|
||||||
|
if netSoftnet { netFlags += 1 }
|
||||||
|
if netHost { netFlags += 1 }
|
||||||
|
|
||||||
|
if netFlags > 1 {
|
||||||
|
throw ValidationError("--net-bridged, --net-softnet and --net-host 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noGraphics || vnc || vncExperimental) && captureSystemKeys {
|
||||||
|
throw ValidationError("--captures-system-keys can only be used with the default VM view")
|
||||||
|
}
|
||||||
|
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
if try vmDir.state() == "suspended" {
|
if try vmDir.state() == .Suspended {
|
||||||
suspendable = true
|
suspendable = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if suspendable {
|
||||||
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
if !(config.platform is PlatformSuspendable) {
|
||||||
|
throw ValidationError("You can only suspend macOS VMs")
|
||||||
|
}
|
||||||
|
if dir.count > 0 {
|
||||||
|
throw ValidationError("Suspending VMs with shared directories is not supported")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for disk in disk {
|
||||||
|
if disk.hasSuffix("-amd64.iso") {
|
||||||
|
throw ValidationError("Seems you have a disk targeting x86 architecture (hence amd64 in the name). Please use an 'arm64' version of the disk.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -126,40 +193,24 @@ struct Run: AsyncParsableCommand {
|
|||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
if try vmDir.state() == "suspended" {
|
try storageLock.lock()
|
||||||
try storageLock.lock() // lock before checking
|
// check if there is a running VM with the same MAC address
|
||||||
let needToGenerateNewMac = try localStorage.list().contains {
|
let hasRunningMACCollision = try localStorage.list().contains {
|
||||||
// check if there is a running VM with the same MAC but different name
|
// 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
|
try $1.running() && $1.macAddress() == vmDir.macAddress() && $1.name != vmDir.name
|
||||||
}
|
}
|
||||||
|
if hasRunningMACCollision {
|
||||||
if needToGenerateNewMac {
|
print("There is already a running VM with the same MAC address!")
|
||||||
print("There is already a running VM with the same MAC address!")
|
print("Resetting VM to assign a new MAC address...")
|
||||||
print("Resetting VM to assign a new MAC address...")
|
try vmDir.regenerateMACAddress()
|
||||||
try vmDir.regenerateMACAddress()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if netSoftnet && isInteractiveSession() {
|
if (netSoftnet || netHost) && isInteractiveSession() {
|
||||||
try Softnet.configureSUIDBitIfNeeded()
|
try Softnet.configureSUIDBitIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
let additionalDiskAttachments = try additionalDiskAttachments()
|
let additionalDiskAttachments = try additionalDiskAttachments()
|
||||||
|
|
||||||
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
|
||||||
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
|
||||||
for additionalDiskAttachment in additionalDiskAttachments {
|
|
||||||
// Read-only attachments do not seem to acquire the lock
|
|
||||||
if additionalDiskAttachment.isReadOnly {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
|
|
||||||
throw RuntimeError.DiskAlreadyInUse("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
|
|
||||||
+ "unmount it first in Finder")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var serialPorts: [VZSerialPortConfiguration] = []
|
var serialPorts: [VZSerialPortConfiguration] = []
|
||||||
if serial {
|
if serial {
|
||||||
let tty_fd = createPTY()
|
let tty_fd = createPTY()
|
||||||
@@ -181,10 +232,12 @@ struct Run: AsyncParsableCommand {
|
|||||||
vm = try VM(
|
vm = try VM(
|
||||||
vmDir: vmDir,
|
vmDir: vmDir,
|
||||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
additionalStorageDevices: additionalDiskAttachments,
|
||||||
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
directorySharingDevices: directoryShares() + rosettaDirectoryShare(),
|
||||||
serialPorts: serialPorts,
|
serialPorts: serialPorts,
|
||||||
suspendable: suspendable
|
suspendable: suspendable,
|
||||||
|
audio: !noAudio,
|
||||||
|
clipboard: !noClipboard
|
||||||
)
|
)
|
||||||
|
|
||||||
let vncImpl: VNC? = try {
|
let vncImpl: VNC? = try {
|
||||||
@@ -210,7 +263,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
// configuration file, otherwise we will loose the lock.
|
// configuration file, otherwise we will loose the lock.
|
||||||
//
|
//
|
||||||
// [1]: https://man.openbsd.org/fcntl
|
// [1]: https://man.openbsd.org/fcntl
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
if try !lock.trylock() {
|
if try !lock.trylock() {
|
||||||
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
throw RuntimeError.VMAlreadyRunning("VM \"\(name)\" is already running!")
|
||||||
}
|
}
|
||||||
@@ -220,8 +273,24 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
|
var resume = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
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...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
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(netBridged: !netBridged.isEmpty)
|
||||||
|
|
||||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||||
print("VNC server is running at \(vncURL)")
|
print("VNC server is running at \(vncURL)")
|
||||||
@@ -231,19 +300,7 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var resume = false
|
try await vm!.run()
|
||||||
|
|
||||||
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!.run(recovery: recovery, resume: resume)
|
|
||||||
|
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
try vncImpl.stop()
|
try vncImpl.stop()
|
||||||
@@ -274,23 +331,25 @@ struct Run: AsyncParsableCommand {
|
|||||||
sigusr1Src.setEventHandler {
|
sigusr1Src.setEventHandler {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
if #available(macOS 14, *) {
|
#if arch(arm64)
|
||||||
try vm!.configuration.validateSaveRestoreSupport()
|
if #available(macOS 14, *) {
|
||||||
|
try vm!.configuration.validateSaveRestoreSupport()
|
||||||
|
|
||||||
print("pausing VM to take a snapshot...")
|
print("pausing VM to take a snapshot...")
|
||||||
try await vm!.virtualMachine.pause()
|
try await vm!.virtualMachine.pause()
|
||||||
|
|
||||||
print("creating a snapshot...")
|
print("creating a snapshot...")
|
||||||
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
try await vm!.virtualMachine.saveMachineStateTo(url: vmDir.stateURL)
|
||||||
|
|
||||||
print("snapshot created successfully! shutting down the VM...")
|
print("snapshot created successfully! shutting down the VM...")
|
||||||
|
|
||||||
task.cancel()
|
task.cancel()
|
||||||
} else {
|
} else {
|
||||||
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
print(RuntimeError.SuspendFailed("this functionality is only supported on macOS 14 (Sonoma) or newer"))
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
} catch (let e) {
|
} catch (let e) {
|
||||||
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
print(RuntimeError.SuspendFailed(e.localizedDescription))
|
||||||
|
|
||||||
@@ -302,9 +361,11 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||||
if noGraphics || useVNCWithoutGraphics {
|
if noGraphics || useVNCWithoutGraphics {
|
||||||
dispatchMain()
|
// enter the main even loop, without bringing up any UI,
|
||||||
|
// and just wait for the VM to exit.
|
||||||
|
NSApplication.shared.run()
|
||||||
} else {
|
} else {
|
||||||
runUI(suspendable)
|
runUI(suspendable, captureSystemKeys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,26 +387,33 @@ struct Run: AsyncParsableCommand {
|
|||||||
if netSoftnet {
|
if netSoftnet {
|
||||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
|
||||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
var extraArguments: [String] = []
|
||||||
|
|
||||||
|
if let netSoftnetAllow = netSoftnetAllow {
|
||||||
|
extraArguments += ["--allow", netSoftnetAllow]
|
||||||
|
}
|
||||||
|
|
||||||
|
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: extraArguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let netBridged = netBridged {
|
if netHost {
|
||||||
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
|
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
|
return try Softnet(vmMACAddress: config.macAddress.string, extraArguments: ["--vm-net-type", "host"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if netBridged.count > 0 {
|
||||||
|
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
|
||||||
|
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
|
||||||
@@ -363,23 +431,69 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
func additionalDiskAttachments() throws -> [VZStorageDeviceConfiguration] {
|
||||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
var result: [VZStorageDeviceConfiguration] = []
|
||||||
let readOnlySuffix = ":ro"
|
let readOnlySuffix = ":ro"
|
||||||
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||||
|
|
||||||
for rawDisk in expandedDiskPaths {
|
for rawDisk in expandedDiskPaths {
|
||||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
let diskReadOnly = rawDisk.hasSuffix(readOnlySuffix)
|
||||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
let diskPath = diskReadOnly ? String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count)) : rawDisk
|
||||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
|
||||||
readOnly: true
|
let diskURL = URL(string: diskPath)
|
||||||
))
|
if (["nbd", "nbds", "nbd+unix", "nbds+unix"].contains(diskURL?.scheme)) {
|
||||||
} else {
|
guard #available(macOS 14, *) else {
|
||||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
throw UnsupportedOSError("attaching Network Block Devices", "are")
|
||||||
url: URL(fileURLWithPath: rawDisk),
|
}
|
||||||
readOnly: false
|
|
||||||
))
|
let nbdAttachment = try VZNetworkBlockDeviceStorageDeviceAttachment(
|
||||||
|
url: diskURL!,
|
||||||
|
timeout: 30,
|
||||||
|
isForcedReadOnly: diskReadOnly,
|
||||||
|
synchronizationMode: VZDiskSynchronizationMode.none
|
||||||
|
)
|
||||||
|
result.append(VZVirtioBlockDeviceConfiguration(attachment: nbdAttachment))
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let diskFileURL = URL(fileURLWithPath: diskPath)
|
||||||
|
|
||||||
|
if pathHasMode(diskPath, mode: S_IFBLK) {
|
||||||
|
guard #available(macOS 14, *) else {
|
||||||
|
throw UnsupportedOSError("attaching block devices", "are")
|
||||||
|
}
|
||||||
|
|
||||||
|
let fd = open(diskPath, diskReadOnly ? O_RDONLY : O_RDWR)
|
||||||
|
if fd == -1 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
switch details.rawValue {
|
||||||
|
case EBUSY:
|
||||||
|
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "already in use, try umounting it via \"diskutil unmountDisk\" (when the whole disk) or \"diskutil umount\" (when mounting a single partition)")
|
||||||
|
case EACCES:
|
||||||
|
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "permission denied, consider changing the disk's owner using \"sudo chown $USER \(diskFileURL.url.path)\" or run Tart as a superuser (see --disk help for more details on how to do that correctly)")
|
||||||
|
default:
|
||||||
|
throw RuntimeError.FailedToOpenBlockDevice(diskFileURL.url.path, "\(details)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockAttachment = try VZDiskBlockDeviceStorageDeviceAttachment(fileHandle: FileHandle(fileDescriptor: fd, closeOnDealloc: true),
|
||||||
|
readOnly: diskReadOnly, synchronizationMode: .full)
|
||||||
|
result.append(VZVirtioBlockDeviceConfiguration(attachment: blockAttachment))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
|
||||||
|
// see https://github.com/cirruslabs/tart/issues/323 for more details.
|
||||||
|
if try !diskReadOnly && !FileLock(lockURL: diskFileURL).trylock() {
|
||||||
|
throw RuntimeError.DiskAlreadyInUse("disk \(diskFileURL.url.path) seems to be already in use, unmount it first in Finder")
|
||||||
|
}
|
||||||
|
|
||||||
|
let diskImageAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||||
|
url: diskFileURL,
|
||||||
|
readOnly: diskReadOnly
|
||||||
|
)
|
||||||
|
result.append(VZVirtioBlockDeviceConfiguration(attachment: diskImageAttachment))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -394,126 +508,130 @@ struct Run: AsyncParsableCommand {
|
|||||||
throw UnsupportedOSError("directory sharing", "is")
|
throw UnsupportedOSError("directory sharing", "is")
|
||||||
}
|
}
|
||||||
|
|
||||||
var directoryShares: [DirectoryShare] = []
|
var allDirectoryShares: [DirectoryShare] = []
|
||||||
|
|
||||||
var allNamedShares = true
|
|
||||||
for rawDir in dir {
|
for rawDir in dir {
|
||||||
let directoryShare = try DirectoryShare(parseFrom: rawDir)
|
allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir))
|
||||||
if (directoryShare.name == nil) {
|
}
|
||||||
allNamedShares = false
|
|
||||||
|
return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in
|
||||||
|
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag)
|
||||||
|
|
||||||
|
var allNamedShares = true
|
||||||
|
for directoryShare in directoryShares {
|
||||||
|
if directoryShare.name == nil {
|
||||||
|
allNamedShares = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
directoryShares.append(directoryShare)
|
if directoryShares.count == 1 && directoryShares.first!.name == nil {
|
||||||
|
let directoryShare = directoryShares.first!
|
||||||
|
let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration())
|
||||||
|
sharingDevice.share = singleDirectoryShare
|
||||||
|
} else if !allNamedShares {
|
||||||
|
throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named")
|
||||||
|
} else {
|
||||||
|
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||||
|
try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() }
|
||||||
|
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sharingDevice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
|
||||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||||
guard let rosettaTag = rosettaTag else {
|
guard let rosettaTag = rosettaTag else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
#if arch(arm64)
|
||||||
|
guard #available(macOS 13, *) else {
|
||||||
|
throw UnsupportedOSError("Rosetta directory share", "is")
|
||||||
|
}
|
||||||
|
|
||||||
guard #available(macOS 13, *) else {
|
switch VZLinuxRosettaDirectoryShare.availability {
|
||||||
throw UnsupportedOSError("Rosetta directory share", "is")
|
case .notInstalled:
|
||||||
}
|
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
||||||
|
case .notSupported:
|
||||||
|
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
switch VZLinuxRosettaDirectoryShare.availability {
|
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
||||||
case .notInstalled:
|
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
||||||
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
|
device.share = try VZLinuxRosettaDirectoryShare()
|
||||||
case .notSupported:
|
|
||||||
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
|
return [device]
|
||||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
|
#elseif arch(x86_64)
|
||||||
device.share = try VZLinuxRosettaDirectoryShare()
|
// there is no Rosetta on Intel
|
||||||
|
return []
|
||||||
return [device]
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func runUI(_ suspendable: Bool) {
|
private func runUI(_ suspendable: Bool, _ captureSystemKeys: Bool) {
|
||||||
let nsApp = NSApplication.shared
|
MainApp.suspendable = suspendable
|
||||||
nsApp.setActivationPolicy(.regular)
|
MainApp.capturesSystemKeys = captureSystemKeys
|
||||||
nsApp.activate(ignoringOtherApps: true)
|
MainApp.main()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
struct MainApp: App {
|
||||||
|
static var suspendable: Bool = false
|
||||||
|
static var capturesSystemKeys: Bool = false
|
||||||
|
|
||||||
struct MainApp: App {
|
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
||||||
static var disappearSignal: Int32 = SIGINT
|
|
||||||
|
|
||||||
@NSApplicationDelegateAdaptor private var appDelegate: MinimalMenuAppDelegate
|
var body: some Scene {
|
||||||
|
WindowGroup(vm!.name) {
|
||||||
var body: some Scene {
|
Group {
|
||||||
WindowGroup(vm!.name) {
|
VMView(vm: vm!, capturesSystemKeys: MainApp.capturesSystemKeys).onAppear {
|
||||||
Group {
|
NSWindow.allowsAutomaticWindowTabbing = false
|
||||||
VMView(vm: vm!).onAppear {
|
}.onDisappear {
|
||||||
NSWindow.allowsAutomaticWindowTabbing = false
|
let ret = kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT)
|
||||||
}.onDisappear {
|
if ret != 0 {
|
||||||
let ret = kill(getpid(), MainApp.disappearSignal)
|
// Fallback to the old termination method that doesn't
|
||||||
if ret != 0 {
|
// propagate the cancellation to Task's in case graceful
|
||||||
// Fallback to the old termination method that doesn't
|
// termination via kill(2) is not successful
|
||||||
// propagate the cancellation to Task's in case graceful
|
NSApplication.shared.terminate(self)
|
||||||
// termination via kill(2) is not successful
|
}
|
||||||
NSApplication.shared.terminate(self)
|
}
|
||||||
}
|
}.frame(
|
||||||
}
|
minWidth: CGFloat(vm!.config.display.width),
|
||||||
}.frame(
|
idealWidth: CGFloat(vm!.config.display.width),
|
||||||
minWidth: CGFloat(vm!.config.display.width),
|
maxWidth: .infinity,
|
||||||
idealWidth: CGFloat(vm!.config.display.width),
|
minHeight: CGFloat(vm!.config.display.height),
|
||||||
maxWidth: .infinity,
|
idealHeight: CGFloat(vm!.config.display.height),
|
||||||
minHeight: CGFloat(vm!.config.display.height),
|
maxHeight: .infinity
|
||||||
idealHeight: CGFloat(vm!.config.display.height),
|
)
|
||||||
maxHeight: .infinity
|
}.commands {
|
||||||
)
|
// Remove some standard menu options
|
||||||
}.commands {
|
CommandGroup(replacing: .help, addition: {})
|
||||||
// Remove some standard menu options
|
CommandGroup(replacing: .newItem, addition: {})
|
||||||
CommandGroup(replacing: .help, addition: {})
|
CommandGroup(replacing: .pasteboard, addition: {})
|
||||||
CommandGroup(replacing: .newItem, addition: {})
|
CommandGroup(replacing: .textEditing, addition: {})
|
||||||
CommandGroup(replacing: .pasteboard, addition: {})
|
CommandGroup(replacing: .undoRedo, addition: {})
|
||||||
CommandGroup(replacing: .textEditing, addition: {})
|
CommandGroup(replacing: .windowSize, addition: {})
|
||||||
CommandGroup(replacing: .undoRedo, addition: {})
|
// Replace some standard menu options
|
||||||
CommandGroup(replacing: .windowSize, addition: {})
|
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
||||||
// Replace some standard menu options
|
CommandMenu("Control") {
|
||||||
CommandGroup(replacing: .appInfo) { AboutTart(config: vm!.config) }
|
Button("Start") {
|
||||||
CommandMenu("Control") {
|
Task { try await vm!.virtualMachine.start() }
|
||||||
Button("Start") {
|
}
|
||||||
Task { try await vm!.virtualMachine.start() }
|
Button("Stop") {
|
||||||
}
|
Task { try await vm!.virtualMachine.stop() }
|
||||||
Button("Stop") {
|
}
|
||||||
Task { try await vm!.virtualMachine.stop() }
|
Button("Request Stop") {
|
||||||
}
|
Task { try vm!.virtualMachine.requestStop() }
|
||||||
Button("Request Stop") {
|
}
|
||||||
Task { try vm!.virtualMachine.requestStop() }
|
if #available(macOS 14, *) {
|
||||||
}
|
if (MainApp.suspendable) {
|
||||||
if #available(macOS 14, *) {
|
Button("Suspend") {
|
||||||
Button("Suspend") {
|
kill(getpid(), SIGUSR1)
|
||||||
kill(getpid(), SIGUSR1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MainApp.disappearSignal = suspendable ? SIGUSR1 : SIGINT
|
|
||||||
MainApp.main()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +641,18 @@ class MinimalMenuAppDelegate: NSObject, NSApplicationDelegate, ObservableObject
|
|||||||
|
|
||||||
func applicationDidFinishLaunching(_ : Notification) {
|
func applicationDidFinishLaunching(_ : Notification) {
|
||||||
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
NSApplication.shared.mainMenu?.removeItem(at: indexOfEditMenu)
|
||||||
|
|
||||||
|
let nsApp = NSApplication.shared
|
||||||
|
nsApp.setActivationPolicy(.regular)
|
||||||
|
nsApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||||
|
if (kill(getpid(), MainApp.suspendable ? SIGUSR1 : SIGINT) == 0) {
|
||||||
|
return .terminateLater
|
||||||
|
} else {
|
||||||
|
return .terminateNow
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,18 +692,19 @@ struct VMView: NSViewRepresentable {
|
|||||||
typealias NSViewType = VZVirtualMachineView
|
typealias NSViewType = VZVirtualMachineView
|
||||||
|
|
||||||
@ObservedObject var vm: VM
|
@ObservedObject var vm: VM
|
||||||
|
var capturesSystemKeys: Bool
|
||||||
|
|
||||||
func makeNSView(context: Context) -> NSViewType {
|
func makeNSView(context: Context) -> NSViewType {
|
||||||
let machineView = VZVirtualMachineView()
|
let machineView = VZVirtualMachineView()
|
||||||
|
|
||||||
// Do not capture system keys so that shortcuts like
|
machineView.capturesSystemKeys = capturesSystemKeys
|
||||||
// Shift-Command-4 + Space (capture a screenshot of window)
|
|
||||||
// work on the host instead of the guest
|
|
||||||
machineView.capturesSystemKeys = false
|
|
||||||
|
|
||||||
// Enable automatic display reconfiguration
|
// Enable automatic display reconfiguration
|
||||||
// for guests that support it
|
// for guests that support it
|
||||||
if #available(macOS 14.0, *) {
|
//
|
||||||
|
// This is disabled for Linux because of poor HiDPI
|
||||||
|
// support, which manifests in fonts being too small
|
||||||
|
if #available(macOS 14.0, *), vm.config.os != .linux {
|
||||||
machineView.automaticallyReconfiguresDisplay = true
|
machineView.automaticallyReconfiguresDisplay = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,38 +720,147 @@ struct DirectoryShare {
|
|||||||
let name: String?
|
let name: String?
|
||||||
let path: URL
|
let path: URL
|
||||||
let readOnly: Bool
|
let readOnly: Bool
|
||||||
|
let mountTag: String
|
||||||
|
|
||||||
init(parseFrom: String) throws {
|
init(parseFrom: String) throws {
|
||||||
let splits = parseFrom.split(maxSplits: 2) { $0 == ":" }
|
var parseFrom = parseFrom
|
||||||
|
|
||||||
if splits.count == 3 {
|
// Consume options
|
||||||
if splits[2] == "ro" {
|
(self.readOnly, self.mountTag, parseFrom) = Self.parseOptions(parseFrom)
|
||||||
readOnly = true
|
|
||||||
} else {
|
// Special case for URLs
|
||||||
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
|
if parseFrom.hasPrefix("http:") || parseFrom.hasPrefix("https:") {
|
||||||
}
|
self.name = nil
|
||||||
name = String(splits[0])
|
self.path = URL(string: parseFrom)!
|
||||||
path = String(splits[1]).toFilePathURL()
|
|
||||||
} else if splits.count == 2 {
|
return
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let arguments = parseFrom.split(separator: ":", maxSplits: 1)
|
||||||
|
|
||||||
|
if arguments.count == 2 {
|
||||||
|
self.name = String(arguments[0])
|
||||||
|
self.path = String(arguments[1]).toRemoteOrLocalURL()
|
||||||
|
} else {
|
||||||
|
self.name = nil
|
||||||
|
self.path = String(arguments[0]).toRemoteOrLocalURL()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseOptions(_ parseFrom: String) -> (Bool, String, String) {
|
||||||
|
var arguments = parseFrom.split(separator: ":")
|
||||||
|
let options = arguments.last!.split(separator: ",")
|
||||||
|
|
||||||
|
var readOnly: Bool = false
|
||||||
|
var mountTag: String = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||||
|
|
||||||
|
var found: Bool = false
|
||||||
|
|
||||||
|
for option in options {
|
||||||
|
switch true {
|
||||||
|
case option == "ro":
|
||||||
|
readOnly = true
|
||||||
|
found = true
|
||||||
|
case option.hasPrefix("tag="):
|
||||||
|
mountTag = String(option.dropFirst(4))
|
||||||
|
found = true
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if found {
|
||||||
|
arguments.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (readOnly, mountTag, arguments.joined(separator: ":"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createConfiguration() throws -> VZSharedDirectory {
|
||||||
|
if (path.isFileURL) {
|
||||||
|
return VZSharedDirectory(url: path, readOnly: readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlCache = URLCache(memoryCapacity: 0, diskCapacity: 1 * 1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
let archiveRequest = URLRequest(url: path, cachePolicy: .returnCacheDataElseLoad)
|
||||||
|
var response: CachedURLResponse? = urlCache.cachedResponse(for: archiveRequest)
|
||||||
|
if (response == nil || response?.data.isEmpty == true) {
|
||||||
|
print("Downloading \(path)...")
|
||||||
|
// download and unarchive remote directories if needed here
|
||||||
|
// use old school API to prevent deadlocks since we are running via MainActor
|
||||||
|
let downloadSemaphore = DispatchSemaphore(value: 0)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let (archiveData, archiveResponse) = try await URLSession.shared.data(for: archiveRequest)
|
||||||
|
if archiveData.isEmpty {
|
||||||
|
print("Remote archive is empty!")
|
||||||
|
} else {
|
||||||
|
urlCache.storeCachedResponse(CachedURLResponse(response: archiveResponse, data: archiveData, storagePolicy: .allowed), for: archiveRequest)
|
||||||
|
print("Cached for future invocations!")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Download failed: \(error)")
|
||||||
|
}
|
||||||
|
downloadSemaphore.signal()
|
||||||
|
}
|
||||||
|
downloadSemaphore.wait()
|
||||||
|
response = urlCache.cachedResponse(for: archiveRequest)
|
||||||
|
} else {
|
||||||
|
print("Using cached archive for \(path)...")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response == nil) {
|
||||||
|
throw ValidationError("Failed to fetch a remote archive!")
|
||||||
|
}
|
||||||
|
|
||||||
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".volume")
|
||||||
|
try FileManager.default.createDirectory(atPath: temporaryLocation.path, withIntermediateDirectories: true)
|
||||||
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
|
try lock.lock()
|
||||||
|
|
||||||
|
guard let executableURL = resolveBinaryPath("tar") else {
|
||||||
|
throw ValidationError("tar not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process.init()
|
||||||
|
process.executableURL = executableURL
|
||||||
|
process.currentDirectoryURL = temporaryLocation
|
||||||
|
process.arguments = ["-xz"]
|
||||||
|
|
||||||
|
let inPipe = Pipe()
|
||||||
|
process.standardInput = inPipe
|
||||||
|
process.launch()
|
||||||
|
|
||||||
|
inPipe.fileHandleForWriting.write(response!.data)
|
||||||
|
try inPipe.fileHandleForWriting.close()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
throw ValidationError("Unarchiving failed!")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Unarchived into a temporary directory!")
|
||||||
|
|
||||||
|
return VZSharedDirectory(url: temporaryLocation, readOnly: readOnly)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
func toFilePathURL() -> URL {
|
func toRemoteOrLocalURL() -> URL {
|
||||||
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
if (starts(with: "https://") || starts(with: "https://")) {
|
||||||
|
URL(string: self)!
|
||||||
|
} else {
|
||||||
|
URL(fileURLWithPath: NSString(string: self).expandingTildeInPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pathHasMode(_ path: String, mode: mode_t) -> Bool {
|
||||||
|
var st = stat()
|
||||||
|
let statRes = stat(path, &st)
|
||||||
|
guard statRes != -1 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (st.st_mode & S_IFMT) == mode
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Set: AsyncParsableCommand {
|
struct Set: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of VM CPUs")
|
@Option(help: "Number of VM CPUs")
|
||||||
@@ -16,7 +17,26 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
|
|
||||||
@Option(help: .hidden)
|
@Flag(help: ArgumentHelp("Generate a new random MAC address for the VM."))
|
||||||
|
var randomMAC: Bool = false
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
@Flag(help: ArgumentHelp("Generate a new random serial number for the macOS VM."))
|
||||||
|
#endif
|
||||||
|
var randomSerial: Bool = false
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Resize the VMs disk to the specified size in GB (note that the disk size can only be increased to avoid losing data)",
|
||||||
|
discussion: """
|
||||||
|
Disk resizing works on most cloud-ready Linux distributions out-of-the box (e.g. Ubuntu Cloud Images
|
||||||
|
have the \"cloud-initramfs-growroot\" package installed that runs on boot) and on the rest of the
|
||||||
|
distributions by running the \"growpart\" or \"resize2fs\" commands.
|
||||||
|
|
||||||
|
For macOS, however, things are a bit more complicated: you need to remove the recovery partition
|
||||||
|
first and then run various \"diskutil\" commands, see Tart's packer plugin source code for more
|
||||||
|
details[1].
|
||||||
|
|
||||||
|
[1]: https://github.com/cirruslabs/packer-plugin-tart/blob/main/builder/tart/step_disk_resize.go
|
||||||
|
"""))
|
||||||
var diskSize: UInt16?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -40,6 +60,17 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if randomMAC {
|
||||||
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
if randomSerial {
|
||||||
|
let oldPlatform = vmConfig.platform as! Darwin
|
||||||
|
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
try vmConfig.save(toURL: vmDir.configURL)
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
if diskSize != nil {
|
if diskSize != nil {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import SwiftDate
|
|||||||
struct Stop: AsyncParsableCommand {
|
struct Stop: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||||
@@ -15,12 +15,12 @@ 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() {
|
switch try vmDir.state() {
|
||||||
case "suspended":
|
case .Suspended:
|
||||||
try stopSuspended(vmDir)
|
try stopSuspended(vmDir)
|
||||||
case "running":
|
case .Running:
|
||||||
try await stopRunning(vmDir)
|
try await stopRunning(vmDir)
|
||||||
default:
|
case .Stopped:
|
||||||
return
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
var pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to gracefully terminate the VM
|
// Try to gracefully terminate the VM
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import SwiftDate
|
|||||||
struct Suspend: AsyncParsableCommand {
|
struct Suspend: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
let pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||||
return credentialsFromAuth
|
return credentialsFromAuth
|
||||||
}
|
}
|
||||||
if let helperProgram = config.credHelpers?[host] {
|
if let helperProgram = try config.findCredHelper(host: host) {
|
||||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +41,18 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
if let outputData = outputData {
|
||||||
|
print(String(decoding: outputData, as: UTF8.self))
|
||||||
|
}
|
||||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||||
}
|
}
|
||||||
|
if outputData == nil || outputData?.count == 0 {
|
||||||
|
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||||
|
}
|
||||||
|
|
||||||
let getOutput = try JSONDecoder().decode(
|
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
)
|
|
||||||
return (getOutput.Username, getOutput.Secret)
|
return (getOutput.Username, getOutput.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +64,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
struct DockerConfig: Codable {
|
struct DockerConfig: Codable {
|
||||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||||
|
|
||||||
|
func findCredHelper(host: String) throws -> String? {
|
||||||
|
// Tart supports wildcards in credHelpers
|
||||||
|
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
|
||||||
|
|
||||||
|
guard let credHelpers else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for (hostPattern, helperProgram) in credHelpers {
|
||||||
|
if (hostPattern == host) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
let compiledPattern = try? Regex(hostPattern)
|
||||||
|
if (try compiledPattern?.wholeMatch(in: host) != nil) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DockerAuthConfig: Codable {
|
struct DockerAuthConfig: Codable {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
kSecAttrLabel as String: "Tart Credentials",
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
]
|
]
|
||||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||||
kSecValueData as String: passwordData,
|
kSecValueData as String: passwordData as Any,
|
||||||
]
|
]
|
||||||
|
|
||||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StdinCredentials {
|
|||||||
return (user, password)
|
return (user, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
|
||||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||||
throw StdinCredentialsError.CredentialRequired(which: name)
|
throw StdinCredentialsError.CredentialRequired(which: name)
|
||||||
|
|||||||
@@ -3,19 +3,32 @@ import AsyncAlgorithms
|
|||||||
|
|
||||||
fileprivate let urlSession = createURLSession()
|
fileprivate let urlSession = createURLSession()
|
||||||
|
|
||||||
class Fetcher {
|
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
let progress: Progress
|
||||||
if viaFile {
|
init(_ progress: Progress) throws {
|
||||||
return try await fetchViaFile(request)
|
self.progress = progress
|
||||||
}
|
|
||||||
|
|
||||||
return try await fetchViaMemory(request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {
|
||||||
|
self.progress.addChild(task.progress, withPendingUnitCount: self.progress.totalUnitCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let delegate = progress != nil ? try DownloadDelegate(progress!) : nil
|
||||||
|
|
||||||
|
if viaFile {
|
||||||
|
return try await fetchViaFile(request, delegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await fetchViaMemory(request, delegate: delegate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaMemory(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
let (data, response) = try await urlSession.data(for: request)
|
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await dataCh.send(data)
|
await dataCh.send(data)
|
||||||
@@ -26,21 +39,21 @@ class Fetcher {
|
|||||||
return (dataCh, response as! HTTPURLResponse)
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private static func fetchViaFile(_ request: URLRequest, delegate: URLSessionTaskDelegate? = nil) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
let (fileURL, response) = try await urlSession.download(for: request)
|
let (fileURL, response) = try await urlSession.download(for: request, delegate: delegate)
|
||||||
|
|
||||||
// Acquire a handle to the downloaded file and then remove it.
|
// Acquire a handle to the downloaded file and then remove it.
|
||||||
//
|
//
|
||||||
// This keeps a working reference to that file, yet we don't
|
// This keeps a working reference to that file, yet we don't
|
||||||
// have to deal with the cleanup any more.
|
// have to deal with the cleanup any more.
|
||||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
let mappedFile = try Data(contentsOf: fileURL, options: [.alwaysMapped])
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
for chunk in (0 ..< mappedFile.count).chunks(ofCount: 64 * 1024 * 1024) {
|
||||||
await dataCh.send(data)
|
await dataCh.send(mappedFile.subdata(in: chunk))
|
||||||
}
|
}
|
||||||
|
|
||||||
dataCh.finish()
|
dataCh.finish()
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LocalLayerCache {
|
||||||
|
private let mappedDisk: Data
|
||||||
|
private var digestToRange: [String : Range<Data.Index>] = [:]
|
||||||
|
|
||||||
|
init?(_ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||||
|
// mmap(2) the disk that contains the layers from the manifest
|
||||||
|
self.mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Record the ranges of the disk layers listed in the manifest
|
||||||
|
var offset: UInt64 = 0
|
||||||
|
|
||||||
|
for layer in manifest.layers.filter({ $0.mediaType == diskV2MediaType }) {
|
||||||
|
guard let uncompressedSize = layer.uncompressedSize() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.digestToRange[layer.digest] = Int(offset)..<Int(offset+uncompressedSize)
|
||||||
|
|
||||||
|
offset += uncompressedSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func find(_ digest: String) -> Data? {
|
||||||
|
guard let foundRange = self.digestToRange[digest] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.mappedDisk.subdata(in: foundRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
struct Lease {
|
struct Lease {
|
||||||
var mac: MACAddress
|
var mac: MACAddress
|
||||||
var ip: IPv4Address
|
var ip: IPv4Address
|
||||||
|
var expiresAt: Date
|
||||||
|
|
||||||
init?(fromRawLease: [String : String]) {
|
init?(fromRawLease: [String : String]) {
|
||||||
// Retrieve the required fields
|
// Retrieve the required fields
|
||||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||||
|
guard let lease = fromRawLease["lease"] else { return nil }
|
||||||
|
|
||||||
// Parse MAC address
|
// Parse MAC address
|
||||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||||
@@ -26,7 +30,13 @@ struct Lease {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse expiration timestamp
|
||||||
|
guard let leaseTimestamp = lease.hex?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
self.ip = ip
|
self.ip = ip
|
||||||
self.mac = mac
|
self.mac = mac
|
||||||
|
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,18 @@ class Leases {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(_ fromString: String) throws {
|
init(_ fromString: String) throws {
|
||||||
var leases: [MACAddress : Lease] = Dictionary()
|
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||||
|
Lease(fromRawLease: rawLease)
|
||||||
|
}).filter({ lease in
|
||||||
|
lease.expiresAt.isInFuture
|
||||||
|
}).map({ lease in
|
||||||
|
(lease.mac, lease)
|
||||||
|
})
|
||||||
|
|
||||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
self.leases = Dictionary(leases) { (left, right) in
|
||||||
leases[lease.mac] = lease
|
// When duplicate lease is found, prefer a newer lease over the older one
|
||||||
|
(left.expiresAt > right.expiresAt) ? left : right
|
||||||
}
|
}
|
||||||
|
|
||||||
self.leases = leases
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||||
@@ -107,7 +112,7 @@ class Leases {
|
|||||||
return rawLeases
|
return rawLeases
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||||
leases[macAddress]?.ip
|
leases[macAddress]?.ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
protocol Network {
|
protocol Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment
|
func attachments() -> [VZNetworkDeviceAttachment]
|
||||||
func run(_ sema: DispatchSemaphore) throws
|
func run(_ sema: AsyncSemaphore) throws
|
||||||
func stop() async throws
|
func stop() async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
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: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
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: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
|
||||||
import Virtualization
|
|
||||||
import Atomics
|
import Atomics
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import System
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
enum SoftnetError: Error {
|
||||||
case InitializationFailed(why: String)
|
case InitializationFailed(why: String)
|
||||||
@@ -15,7 +16,7 @@ class Softnet: Network {
|
|||||||
|
|
||||||
let vmFD: Int32
|
let vmFD: Int32
|
||||||
|
|
||||||
init(vmMACAddress: String) throws {
|
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
@@ -30,7 +31,7 @@ class Softnet: Network {
|
|||||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
process.executableURL = try Self.softnetExecutableURL()
|
process.executableURL = try Self.softnetExecutableURL()
|
||||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
|||||||
return executableURL
|
return executableURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
monitorTask = Task {
|
monitorTask = Task {
|
||||||
@@ -92,9 +93,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 {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor AuthenticationKeeper {
|
||||||
|
var authentication: Authentication? = nil
|
||||||
|
|
||||||
|
func set(_ authentication: Authentication) {
|
||||||
|
self.authentication = authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
func header() -> (String, String)? {
|
||||||
|
if let authentication = authentication {
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token has expired
|
||||||
|
if !authentication.isValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return authentication.header()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token is not set
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
enum DigestError: Error {
|
||||||
|
case InvalidOffset
|
||||||
|
case InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
class Digest {
|
class Digest {
|
||||||
var hash: SHA256 = SHA256()
|
var hash: SHA256 = SHA256()
|
||||||
|
|
||||||
@@ -15,6 +20,37 @@ class Digest {
|
|||||||
static func hash(_ data: Data) -> String {
|
static func hash(_ data: Data) -> String {
|
||||||
SHA256.hash(data: data).hexdigest()
|
SHA256.hash(data: data).hexdigest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL) throws -> String {
|
||||||
|
hash(try Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
|
||||||
|
// Sanity check
|
||||||
|
let fhSanity = try FileHandle(forReadingFrom: url)
|
||||||
|
try fhSanity.seekToEnd()
|
||||||
|
let fileSize = try fhSanity.offset()
|
||||||
|
try fhSanity.close()
|
||||||
|
|
||||||
|
if offset > fileSize {
|
||||||
|
throw DigestError.InvalidOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + size) > fileSize {
|
||||||
|
throw DigestError.InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk of size ``size`` at offset ``offset``
|
||||||
|
// and calculate it's digest
|
||||||
|
let fh = try FileHandle(forReadingFrom: url)
|
||||||
|
defer { try! fh.close() }
|
||||||
|
|
||||||
|
try fh.seek(toOffset: offset)
|
||||||
|
|
||||||
|
let data = try fh.read(upToCount: Int(size))!
|
||||||
|
|
||||||
|
return hash(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SHA256.Digest {
|
extension SHA256.Digest {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Disk {
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer]
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?) async throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
class DiskV1: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
var mappedDiskReadOffset = 0
|
||||||
|
|
||||||
|
// Compress the disk file as a single stream
|
||||||
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
|
// Determine the size of the next chunk
|
||||||
|
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||||
|
|
||||||
|
// Read the next uncompressed chunk
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
|
||||||
|
// Advance the offset
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
|
// Provide the uncompressed chunk to the compressing filter
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||||
|
// except for the last one, which may be smaller
|
||||||
|
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV1MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using an absolute value
|
||||||
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws {
|
||||||
|
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
defer { try! disk.close() }
|
||||||
|
|
||||||
|
// Decompress the layers onto the disk in a single stream
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
if let data = data {
|
||||||
|
disk.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for diskLayer in diskLayers {
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
class DiskV2: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Compress the disk file as multiple individually decompressible streams,
|
||||||
|
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||||
|
for data in mappedDisk.chunks(ofCount: layerLimitBytes) {
|
||||||
|
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||||
|
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV2MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest,
|
||||||
|
uncompressedSize: UInt64(data.count),
|
||||||
|
uncompressedContentDigest: Digest.hash(data)
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using a relative value
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil) async throws {
|
||||||
|
// Support resumable pulls
|
||||||
|
let pullResumed = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
|
|
||||||
|
if !pullResumed && !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the uncompressed disk size
|
||||||
|
var uncompressedDiskSize: UInt64 = 0
|
||||||
|
|
||||||
|
for layer in diskLayers {
|
||||||
|
guard let uncompressedLayerSize = layer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
uncompressedDiskSize += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate the target disk file so that it will be able
|
||||||
|
// to accomodate the uncompressed disk size
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
// Concurrently fetch and decompress layers
|
||||||
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
|
var globalDiskWritingOffset: UInt64 = 0
|
||||||
|
|
||||||
|
for (index, diskLayer) in diskLayers.enumerated() {
|
||||||
|
// Respect the concurrency limit
|
||||||
|
if index >= concurrency {
|
||||||
|
try await group.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve layer annotations
|
||||||
|
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current disk writing offset
|
||||||
|
let diskWritingOffset = globalDiskWritingOffset
|
||||||
|
|
||||||
|
// Launch a fetching and decompression task
|
||||||
|
group.addTask {
|
||||||
|
// No need to fetch and decompress anything if we've already done so
|
||||||
|
if try pullResumed && Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(diskLayer.size)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
|
||||||
|
// Check if we already have this layer contents in the local layer cache
|
||||||
|
if let localLayerCache = localLayerCache, let data = localLayerCache.find(diskLayer.digest), Digest.hash(data) == uncompressedLayerContentDigest {
|
||||||
|
// Fulfil the layer contents from the local blob cache
|
||||||
|
_ = try zeroSkippingWrite(disk, diskWritingOffset, data)
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(diskLayer.size)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskWritingOffset = diskWritingOffset
|
||||||
|
|
||||||
|
// Pull and decompress a single layer into the specific offset on disk
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
guard let data = data else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diskWritingOffset = try zeroSkippingWrite(disk, diskWritingOffset, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
|
||||||
|
try disk.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
globalDiskWritingOffset += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func zeroSkippingWrite(_ disk: FileHandle, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||||
|
let holeGranularityBytes = 64 * 1024
|
||||||
|
|
||||||
|
// A zero chunk for faster than byte-by-byte comparisons
|
||||||
|
//
|
||||||
|
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||||
|
// on the last block since it costs only 64 KiB of excess data per 500 MB layer.
|
||||||
|
//
|
||||||
|
// Some simple benchmarks ("sync && sudo purge" command was used to negate the disk caching effects):
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Operation | time(1) result |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
// | Data(...) == zeroChunk | 2.16s user 11.71s system 73% cpu 18.928 total |
|
||||||
|
// | Data(...).contains(where: {$0 != 0}) | 603.68s user 12.97s system 99% cpu 10:22.85 total |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
let zeroChunk = Data(count: holeGranularityBytes)
|
||||||
|
|
||||||
|
var offset = offset
|
||||||
|
|
||||||
|
for chunk in data.chunks(ofCount: holeGranularityBytes) {
|
||||||
|
// Only write chunks that are not zero
|
||||||
|
if chunk != zeroChunk {
|
||||||
|
try disk.seek(toOffset: offset)
|
||||||
|
disk.write(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += UInt64(chunk.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// OCI manifest and OCI config media types
|
||||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||||
|
|
||||||
// Annotations
|
// Layer media types
|
||||||
|
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||||
|
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||||
|
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||||
|
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||||
|
|
||||||
|
// Manifest annotations
|
||||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||||
|
|
||||||
|
// Layer annotations
|
||||||
|
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||||
|
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||||
|
|
||||||
struct OCIManifest: Codable, Equatable {
|
struct OCIManifest: Codable, Equatable {
|
||||||
var schemaVersion: Int = 2
|
var schemaVersion: Int = 2
|
||||||
var mediaType: String = ociManifestMediaType
|
var mediaType: String = ociManifestMediaType
|
||||||
@@ -71,6 +82,37 @@ struct OCIManifestLayer: Codable, Equatable {
|
|||||||
var mediaType: String
|
var mediaType: String
|
||||||
var size: Int
|
var size: Int
|
||||||
var digest: String
|
var digest: String
|
||||||
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
|
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.size = size
|
||||||
|
self.digest = digest
|
||||||
|
|
||||||
|
var annotations: [String: String] = [:]
|
||||||
|
|
||||||
|
if let uncompressedSize = uncompressedSize {
|
||||||
|
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let uncompressedContentDigest = uncompressedContentDigest {
|
||||||
|
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
|
||||||
|
}
|
||||||
|
|
||||||
|
self.annotations = annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedSize() -> UInt64? {
|
||||||
|
guard let value = annotations?[uncompressedSizeAnnotation] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return UInt64(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedContentDigest() -> String? {
|
||||||
|
annotations?[uncompressedContentDigestAnnotation]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Descriptor: Equatable {
|
struct Descriptor: Equatable {
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ extension AsyncThrowingChannel<Data, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable, Authentication {
|
struct TokenResponse: Decodable, Authentication {
|
||||||
let defaultIssuedAt = Date()
|
|
||||||
let defaultExpiresIn = 60
|
|
||||||
|
|
||||||
var token: String?
|
var token: String?
|
||||||
var accessToken: String?
|
var accessToken: String?
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
@@ -66,7 +63,8 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = try decoder.decode(TokenResponse.self, from: fromData)
|
var response = try decoder.decode(TokenResponse.self, from: fromData)
|
||||||
|
response.issuedAt = response.issuedAt ?? Date()
|
||||||
|
|
||||||
guard response.token != nil || response.accessToken != nil else {
|
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."))
|
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||||
@@ -85,7 +83,7 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
//
|
//
|
||||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||||
|
|
||||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,8 +100,7 @@ class Registry {
|
|||||||
private let baseURL: URL
|
private let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
|
let authenticationKeeper = AuthenticationKeeper()
|
||||||
var currentAuthToken: Authentication? = nil
|
|
||||||
|
|
||||||
var host: String? {
|
var host: String? {
|
||||||
guard let host = baseURL.host else { return nil }
|
guard let host = baseURL.host else { return nil }
|
||||||
@@ -242,7 +239,7 @@ class Registry {
|
|||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
public func pullBlob(_ digest: String, handler: (Data) async throws -> Void) async throws {
|
||||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await channel.asData().asText()
|
let body = try await channel.asData().asText()
|
||||||
@@ -253,7 +250,7 @@ class Registry {
|
|||||||
for try await part in channel {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(Data(part))
|
try await handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,11 +302,6 @@ class Registry {
|
|||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
|
||||||
if currentAuthToken?.isValid() == false {
|
|
||||||
currentAuthToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
@@ -331,7 +323,7 @@ class Registry {
|
|||||||
|
|
||||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials() {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -378,7 +370,7 @@ class Registry {
|
|||||||
+ "while retrieving an authentication token", details: data.asText())
|
+ "while retrieving an authentication token", details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials() throws -> (String, String)? {
|
private func lookupCredentials() throws -> (String, String)? {
|
||||||
@@ -399,8 +391,7 @@ class Registry {
|
|||||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let (name, value) = await authenticationKeeper.header() {
|
||||||
let (name, value) = token.header()
|
|
||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,129 +6,131 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Darwin: PlatformSuspendable {
|
#if arch(arm64)
|
||||||
var ecid: VZMacMachineIdentifier
|
|
||||||
var hardwareModel: VZMacHardwareModel
|
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
struct Darwin: PlatformSuspendable {
|
||||||
self.ecid = ecid
|
var ecid: VZMacMachineIdentifier
|
||||||
self.hardwareModel = hardwareModel
|
var hardwareModel: VZMacHardwareModel
|
||||||
}
|
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
self.ecid = ecid
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
|
||||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize Data using the provided value")
|
|
||||||
}
|
|
||||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
|
||||||
}
|
|
||||||
self.ecid = ecid
|
|
||||||
|
|
||||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
}
|
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
}
|
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
|
||||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
func os() -> OS {
|
|
||||||
.darwin
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
|
||||||
VZMacOSBootLoader()
|
|
||||||
}
|
|
||||||
|
|
||||||
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
|
||||||
let result = VZMacPlatformConfiguration()
|
|
||||||
|
|
||||||
result.machineIdentifier = ecid
|
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
|
||||||
|
|
||||||
if !hardwareModel.isSupported {
|
|
||||||
// At the moment support of M1 chip is not yet dropped in any macOS version
|
|
||||||
// This mean that host software is not supporting this hardware model and should be updated
|
|
||||||
throw UnsupportedHostOSError()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.hardwareModel = hardwareModel
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
return result
|
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||||
}
|
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize Data using the provided value")
|
||||||
|
}
|
||||||
|
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||||
|
}
|
||||||
|
self.ecid = ecid
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||||
let result = VZMacGraphicsDeviceConfiguration()
|
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
|
||||||
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
|
result.machineIdentifier = ecid
|
||||||
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
|
|
||||||
|
if !hardwareModel.isSupported {
|
||||||
|
// At the moment support of M1 chip is not yet dropped in any macOS version
|
||||||
|
// This mean that host software is not supporting this hardware model and should be updated
|
||||||
|
throw UnsupportedHostOSError()
|
||||||
|
}
|
||||||
|
|
||||||
|
result.hardwareModel = hardwareModel
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
if let hostMainScreen = NSScreen.main {
|
||||||
|
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if let hostMainScreen = NSScreen.main {
|
|
||||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
|
||||||
result.displays = [
|
result.displays = [
|
||||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height,
|
||||||
|
// A reasonable guess according to Apple's documentation[1]
|
||||||
|
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
result.displays = [
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
VZMacGraphicsDisplayConfiguration(
|
if #available(macOS 14, *) {
|
||||||
widthInPixels: vmConfig.display.width,
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
heightInPixels: vmConfig.display.height,
|
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||||
// A reasonable guess according to Apple's documentation[1]
|
} else {
|
||||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
return [VZUSBKeyboardConfiguration()]
|
||||||
pixelsPerInch: 72
|
}
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
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] {
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||||
if #available(macOS 14, *) {
|
if #available(macOS 14, *) {
|
||||||
return [VZMacKeyboardConfiguration()]
|
return [VZMacKeyboardConfiguration()]
|
||||||
} else {
|
} else {
|
||||||
return []
|
// fallback to the regular configuration
|
||||||
|
return keyboards()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
if #available(macOS 13, *) {
|
|
||||||
// Trackpad is only supported by guests starting with macOS Ventura
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||||
} else {
|
}
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return pointingDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
#endif
|
||||||
if #available(macOS 13, *) {
|
|
||||||
return [VZMacTrackpadConfiguration()]
|
|
||||||
} else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
|||||||
var url: URL { get }
|
var url: URL { get }
|
||||||
func delete() throws
|
func delete() throws
|
||||||
func accessDate() throws -> Date
|
func accessDate() throws -> Date
|
||||||
|
// size on disk as seen in Finder including empty blocks
|
||||||
func sizeBytes() throws -> Int
|
func sizeBytes() throws -> Int
|
||||||
|
// actual size on disk without empty blocks
|
||||||
|
func allocatedSizeBytes() throws -> Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -25,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Rename.self,
|
Rename.self,
|
||||||
Stop.self,
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
|
FQN.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
@@ -48,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich future events with Cirrus CI-specific tags
|
// Enrich future events with Cirrus CI-specific tags
|
||||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
@@ -81,10 +87,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 {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
fileprivate func normalizeName(_ name: String) -> String {
|
||||||
|
// Colons are misinterpreted by Zsh completion
|
||||||
|
return name.replacingOccurrences(of: ":", with: "\\:")
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list().map { name, _ in
|
||||||
|
normalizeName(name)
|
||||||
|
}) ?? []
|
||||||
|
let ociVMs = (try? VMStorageOCI().list().map { name, _, _ in
|
||||||
|
normalizeName(name)
|
||||||
|
}) ?? []
|
||||||
|
return (localVMs + ociVMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeLocalMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
|
return localVMs.map { name, _ in normalizeName(name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeRunningMachines(_ arguments: [String]) -> [String] {
|
||||||
|
let localVMs = (try? VMStorageLocal().list()) ?? []
|
||||||
|
return localVMs
|
||||||
|
.filter { _, vmDir in (try? vmDir.state() == .Running) ?? false}
|
||||||
|
.map { name, _ in normalizeName(name) }
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@ extension URL: Prunable {
|
|||||||
try FileManager.default.removeItem(at: self)
|
try FileManager.default.removeItem(at: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func allocatedSizeBytes() throws -> Int {
|
||||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sizeBytes() throws -> Int {
|
||||||
|
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
}
|
}
|
||||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
var configuration: VZVirtualMachineConfiguration
|
var configuration: VZVirtualMachineConfiguration
|
||||||
|
|
||||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||||
var sema = DispatchSemaphore(value: 0)
|
var sema = AsyncSemaphore(value: 0)
|
||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var name: String
|
var name: String
|
||||||
@@ -42,10 +43,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = [],
|
serialPorts: [VZSerialPortConfiguration] = [],
|
||||||
suspendable: Bool = false
|
suspendable: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true
|
||||||
) throws {
|
) throws {
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
@@ -58,10 +61,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
self.network = network
|
self.network = network
|
||||||
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, additionalStorageDevices: additionalStorageDevices,
|
||||||
directorySharingDevices: directorySharingDevices,
|
directorySharingDevices: directorySharingDevices,
|
||||||
serialPorts: serialPorts,
|
serialPorts: serialPorts,
|
||||||
suspendable: suspendable
|
suspendable: suspendable,
|
||||||
|
audio: audio,
|
||||||
|
clipboard: clipboard
|
||||||
)
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
@@ -71,9 +76,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
// Check if we already have this IPSW in cache
|
// Check if we already have this IPSW in cache
|
||||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
var headRequest = URLRequest(url: remoteURL)
|
||||||
|
headRequest.httpMethod = "HEAD"
|
||||||
|
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
|
||||||
|
|
||||||
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
@@ -87,10 +94,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Download the IPSW
|
// Download the IPSW
|
||||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
let downloadProgress = Progress(totalUnitCount: 100)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||||
|
|
||||||
|
let request = URLRequest(url: remoteURL)
|
||||||
|
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||||
|
|
||||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||||
|
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||||
|
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||||
|
ProgressObserver(digestProgress).log(defaultLogger)
|
||||||
|
|
||||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||||
let lock = try FileLock(lockURL: temporaryLocation)
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
@@ -102,7 +116,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
let chunkAsData = Data(chunk)
|
let chunkAsData = Data(chunk)
|
||||||
fileHandle.write(chunkAsData)
|
fileHandle.write(chunkAsData)
|
||||||
digest.update(chunkAsData)
|
digest.update(chunkAsData)
|
||||||
progress.completedUnitCount += Int64(chunk.count)
|
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
try fileHandle.close()
|
try fileHandle.close()
|
||||||
@@ -112,18 +126,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||||
}
|
}
|
||||||
|
|
||||||
static func latestIPSWURL() async throws -> URL {
|
|
||||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
|
||||||
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
var inFinalState: Bool {
|
var inFinalState: Bool {
|
||||||
get {
|
get {
|
||||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||||
@@ -133,82 +135,90 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
#if arch(arm64)
|
||||||
vmDir: VMDirectory,
|
init(
|
||||||
ipswURL: URL,
|
vmDir: VMDirectory,
|
||||||
diskSizeGB: UInt16,
|
ipswURL: URL,
|
||||||
network: Network = NetworkShared(),
|
diskSizeGB: UInt16,
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
network: Network = NetworkShared(),
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = []
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
) async throws {
|
serialPorts: [VZSerialPortConfiguration] = []
|
||||||
var ipswURL = ipswURL
|
) async throws {
|
||||||
|
var ipswURL = ipswURL
|
||||||
|
|
||||||
if !ipswURL.isFileURL {
|
if !ipswURL.isFileURL {
|
||||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||||
}
|
|
||||||
|
|
||||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
|
||||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
|
||||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
|
||||||
ipswURL.resolveSymlinksInPath()
|
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
|
||||||
// that match both the image and our platform
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||||
throw UnsupportedRestoreImageError()
|
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||||
}
|
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||||
|
ipswURL.resolveSymlinksInPath()
|
||||||
|
|
||||||
// Create NVRAM
|
// Load the restore image and try to get the requirements
|
||||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
// that match both the image and our platform
|
||||||
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
// Create disk
|
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
|
||||||
|
|
||||||
name = vmDir.name
|
|
||||||
// Create config
|
|
||||||
config = VMConfig(
|
|
||||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
|
||||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
|
||||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
|
||||||
)
|
|
||||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
|
||||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
|
||||||
try config.save(toURL: vmDir.configURL)
|
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
|
||||||
self.network = network
|
|
||||||
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
|
||||||
vmConfig: config, network: network,
|
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
|
||||||
directorySharingDevices: directorySharingDevices,
|
|
||||||
serialPorts: serialPorts
|
|
||||||
)
|
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
virtualMachine.delegate = self
|
|
||||||
|
|
||||||
// Run automated installation
|
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
||||||
DispatchQueue.main.async { [ipswURL] in
|
|
||||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
|
||||||
|
|
||||||
defaultLogger.appendNewLine("Installing OS...")
|
|
||||||
ProgressObserver(installer.progress).log(defaultLogger)
|
|
||||||
|
|
||||||
installer.install { result in
|
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||||
|
throw UnsupportedRestoreImageError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NVRAM
|
||||||
|
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||||
|
|
||||||
|
// Create disk
|
||||||
|
try vmDir.resizeDisk(diskSizeGB)
|
||||||
|
|
||||||
|
name = vmDir.name
|
||||||
|
// Create config
|
||||||
|
config = VMConfig(
|
||||||
|
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||||
|
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||||
|
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||||
|
)
|
||||||
|
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||||
|
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||||
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
// Initialize the virtual machine and its configuration
|
||||||
|
self.network = network
|
||||||
|
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
|
vmConfig: config, network: network,
|
||||||
|
additionalStorageDevices: additionalStorageDevices,
|
||||||
|
directorySharingDevices: directorySharingDevices,
|
||||||
|
serialPorts: serialPorts
|
||||||
|
)
|
||||||
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
virtualMachine.delegate = self
|
||||||
|
|
||||||
|
// Run automated installation
|
||||||
|
try await install(ipswURL)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@MainActor
|
||||||
|
private func install(_ url: URL) async throws {
|
||||||
|
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: url)
|
||||||
|
defaultLogger.appendNewLine("Installing OS...")
|
||||||
|
ProgressObserver(installer.progress).log(defaultLogger)
|
||||||
|
|
||||||
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
installer.install { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, onCancel: {
|
||||||
|
installer.progress.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||||
@@ -225,7 +235,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try VM(vmDir: vmDir)
|
return try VM(vmDir: vmDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(recovery: Bool, resume shouldResume: Bool) async throws {
|
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||||
try network.run(sema)
|
try network.run(sema)
|
||||||
|
|
||||||
if shouldResume {
|
if shouldResume {
|
||||||
@@ -233,17 +243,21 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
try await start(recovery)
|
try await start(recovery)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await withTaskCancellationHandler(operation: {
|
func run() async throws {
|
||||||
// Wait for the VM to finish running
|
do {
|
||||||
// or for the exit condition
|
try await sema.waitUnlessCancelled()
|
||||||
sema.wait()
|
} catch is CancellationError {
|
||||||
}, onCancel: {
|
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||||
sema.signal()
|
// VM window, so shut down the VM gracefully below.
|
||||||
})
|
}
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
try await stop()
|
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||||
|
print("Stopping VM...")
|
||||||
|
try await stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await network.stop()
|
try await network.stop()
|
||||||
@@ -251,15 +265,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func start(_ recovery: Bool) async throws {
|
private func start(_ recovery: Bool) async throws {
|
||||||
if #available(macOS 13, *) {
|
#if arch(arm64)
|
||||||
// new API introduced in Ventura
|
|
||||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||||
startOptions.startUpFromMacOSRecovery = recovery
|
startOptions.startUpFromMacOSRecovery = recovery
|
||||||
try await virtualMachine.start(options: startOptions)
|
try await virtualMachine.start(options: startOptions)
|
||||||
} else {
|
#else
|
||||||
// use method that also available on Monterey
|
try await virtualMachine.start()
|
||||||
try await virtualMachine.start(recovery)
|
#endif
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -277,10 +289,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||||
serialPorts: [VZSerialPortConfiguration],
|
serialPorts: [VZSerialPortConfiguration],
|
||||||
suspendable: Bool = false
|
suspendable: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -298,7 +312,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
if !suspendable {
|
if audio && !suspendable {
|
||||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||||
@@ -318,15 +332,38 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard sharing via Spice agent
|
||||||
|
if clipboard && vmConfig.os == .linux {
|
||||||
|
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||||
|
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||||
|
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||||
|
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||||
|
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||||
|
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||||
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||||
attachments.append(contentsOf: additionalDiskAttachments)
|
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: .full) :
|
||||||
|
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||||
|
|
||||||
|
var device: VZStorageDeviceConfiguration
|
||||||
|
if #available(macOS 14, *), vmConfig.os == .linux {
|
||||||
|
device = VZNVMExpressControllerDeviceConfiguration(attachment: attachment)
|
||||||
|
} else {
|
||||||
|
device = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||||
|
}
|
||||||
|
var devices: [VZStorageDeviceConfiguration] = [device]
|
||||||
|
devices.append(contentsOf: additionalStorageDevices)
|
||||||
|
configuration.storageDevices = devices
|
||||||
|
|
||||||
// Entropy
|
// Entropy
|
||||||
if !suspendable {
|
if !suspendable {
|
||||||
@@ -343,7 +380,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
//
|
//
|
||||||
// A dummy console device useful for implementing
|
// A dummy console device useful for implementing
|
||||||
// host feature checks in the guest agent software.
|
// host feature checks in the guest agent software.
|
||||||
if #available(macOS 13, *) {
|
if !suspendable {
|
||||||
let consolePort = VZVirtioConsolePortConfiguration()
|
let consolePort = VZVirtioConsolePortConfiguration()
|
||||||
consolePort.name = "tart-version-\(CI.version)"
|
consolePort.name = "tart-version-\(CI.version)"
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,16 @@ struct VMConfig: Codable {
|
|||||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||||
switch os {
|
switch os {
|
||||||
case .darwin:
|
case .darwin:
|
||||||
platform = try Darwin(from: decoder)
|
#if arch(arm64)
|
||||||
|
platform = try Darwin(from: decoder)
|
||||||
|
#else
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
forKey: .os,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "Darwin VMs are only supported on Apple Silicon hosts")
|
||||||
|
#endif
|
||||||
case .linux:
|
case .linux:
|
||||||
if #available(macOS 13, *) {
|
platform = try Linux(from: decoder)
|
||||||
platform = try Linux(from: decoder)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||||
@@ -136,20 +139,30 @@ struct VMConfig: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if os == .darwin && cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||||
|
+ " at minimum (requested \(cpuCount))")
|
||||||
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if os == .darwin && memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySize))")
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if memorySize < VZVirtualMachineConfiguration.minimumAllowedMemorySize {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedMemorySize) bytes"
|
||||||
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
|
}
|
||||||
|
|
||||||
self.memorySize = memorySize
|
self.memorySize = memorySize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,23 @@
|
|||||||
import Foundation
|
|
||||||
import Compression
|
import Compression
|
||||||
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
enum OCIError: Error {
|
enum OCIError: Error {
|
||||||
case ShouldBeExactlyOneLayer
|
case ShouldBeExactlyOneLayer
|
||||||
case ShouldBeAtLeastOneLayer
|
case ShouldBeAtLeastOneLayer
|
||||||
case FailedToCreateVmFile
|
case FailedToCreateVmFile
|
||||||
|
case LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
case LayerIsMissingUncompressedDigestAnnotation
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMDirectory {
|
extension VMDirectory {
|
||||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
|
||||||
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?) async throws {
|
||||||
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
|
||||||
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, reference: String) async throws {
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
|
||||||
|
|
||||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
|
||||||
|
|
||||||
return try await pullFromRegistry(registry: registry, manifest: manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
|
|
||||||
// Pull VM's config file layer and re-serialize it into a config file
|
// Pull VM's config file layer and re-serialize it into a config file
|
||||||
let configLayers = manifest.layers.filter {
|
let configLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.configMediaType
|
$0.mediaType == configMediaType
|
||||||
}
|
}
|
||||||
if configLayers.count != 1 {
|
if configLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -41,50 +31,42 @@ extension VMDirectory {
|
|||||||
}
|
}
|
||||||
try configFile.close()
|
try configFile.close()
|
||||||
|
|
||||||
// Pull VM's disk layers and decompress them sequentially into a disk file
|
// Pull VM's disk layers and decompress them into a disk file
|
||||||
let diskLayers = manifest.layers.filter {
|
let diskImplType: Disk.Type
|
||||||
$0.mediaType == Self.diskMediaType
|
let layers: [OCIManifestLayer]
|
||||||
}
|
|
||||||
if diskLayers.isEmpty {
|
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
|
||||||
|
diskImplType = DiskV1.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
|
||||||
|
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
|
||||||
|
diskImplType = DiskV2.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||||
|
} else {
|
||||||
throw OCIError.ShouldBeAtLeastOneLayer
|
throw OCIError.ShouldBeAtLeastOneLayer
|
||||||
}
|
}
|
||||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
|
||||||
throw OCIError.FailedToCreateVmFile
|
|
||||||
}
|
|
||||||
let disk = try FileHandle(forWritingTo: diskURL)
|
|
||||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
|
||||||
if let data = data {
|
|
||||||
disk.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress
|
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||||
$0.size
|
|
||||||
}
|
|
||||||
.reduce(0) {
|
|
||||||
$0 + $1
|
|
||||||
})
|
|
||||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
for diskLayer in diskLayers {
|
do {
|
||||||
try await registry.pullBlob(diskLayer.digest) { data in
|
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||||
try filter.write(data)
|
concurrency: concurrency, progress: progress,
|
||||||
progress.completedUnitCount += Int64(data.count)
|
localLayerCache: localLayerCache)
|
||||||
}
|
} catch let error where error is FilterError {
|
||||||
|
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
try filter.finalize()
|
|
||||||
try disk.close()
|
|
||||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
|
||||||
|
|
||||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||||
|
|
||||||
let nvramLayers = manifest.layers.filter {
|
let nvramLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.nvramMediaType
|
$0.mediaType == nvramMediaType
|
||||||
}
|
}
|
||||||
if nvramLayers.count != 1 {
|
if nvramLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -99,7 +81,7 @@ extension VMDirectory {
|
|||||||
try nvram.close()
|
try nvram.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
|
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
|
||||||
var layers = Array<OCIManifestLayer>()
|
var layers = Array<OCIManifestLayer>()
|
||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
@@ -107,32 +89,22 @@ extension VMDirectory {
|
|||||||
let configJSON = try JSONEncoder().encode(config)
|
let configJSON = try JSONEncoder().encode(config)
|
||||||
defaultLogger.appendNewLine("pushing config...")
|
defaultLogger.appendNewLine("pushing config...")
|
||||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
|
||||||
|
|
||||||
// Progress
|
// Compress the disk file as multiple chunks and push them as disk layers
|
||||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||||
let progress = Progress(totalUnitCount: diskSize)
|
let progress = Progress(totalUnitCount: diskSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
// Read VM's compressed disk as chunks
|
switch diskFormat {
|
||||||
// and sequentially upload them as blobs
|
case "v1":
|
||||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||||
let mappedDiskSize = mappedDisk.count
|
case "v2":
|
||||||
var mappedDiskReadOffset = 0
|
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
default:
|
||||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
|
||||||
mappedDiskReadOffset += bytesRead
|
|
||||||
|
|
||||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
|
||||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read VM's NVRAM and push it as blob
|
// Read VM's NVRAM and push it as blob
|
||||||
@@ -140,7 +112,7 @@ extension VMDirectory {
|
|||||||
|
|
||||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||||
|
|
||||||
// Craft a stub OCI config for Docker Hub compatibility
|
// Craft a stub OCI config for Docker Hub compatibility
|
||||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||||
@@ -148,7 +120,7 @@ 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(diskSize),
|
||||||
uploadDate: Date()
|
uploadDate: Date()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
|
enum State: String {
|
||||||
|
case Running = "running"
|
||||||
|
case Suspended = "suspended"
|
||||||
|
case Stopped = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
var configURL: URL {
|
var configURL: URL {
|
||||||
@@ -29,26 +36,30 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL
|
baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lock() throws -> PIDLock {
|
||||||
|
try PIDLock(lockURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
func running() throws -> Bool {
|
func running() throws -> Bool {
|
||||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||||
// which is fine to report as "not running".
|
// 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 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.
|
// 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 {
|
guard let lock = try? lock() else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return try lock.pid() != 0
|
return try lock.pid() != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func state() throws -> String {
|
func state() throws -> State {
|
||||||
if try running() {
|
if try running() {
|
||||||
return "running"
|
return State.Running
|
||||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||||
return "suspended"
|
return State.Suspended
|
||||||
} else {
|
} else {
|
||||||
return "stopped"
|
return State.Stopped
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +70,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) &&
|
||||||
@@ -118,20 +140,45 @@ struct VMDirectory: Prunable {
|
|||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
// macOS considers kilo being 1000 and not 1024
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
if desiredDiskFileLength < currentDiskFileLength {
|
||||||
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||||
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||||
|
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||||
|
"than the current disk size of \(currentLengthHuman)")
|
||||||
|
} else if desiredDiskFileLength > currentDiskFileLength {
|
||||||
|
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||||
|
}
|
||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
|
let lock = try lock()
|
||||||
|
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(name)
|
||||||
|
}
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: baseURL)
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
try baseURL.accessDate()
|
try baseURL.accessDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allocatedSizeBytes() throws -> Int {
|
||||||
|
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocatedSizeGB() throws -> Int {
|
||||||
|
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func sizeBytes() throws -> Int {
|
||||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,15 @@ class VMStorageHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NSError {
|
||||||
|
func isFileNotFound() -> Bool {
|
||||||
|
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Error {
|
extension Error {
|
||||||
func isFileNotFound() -> Bool {
|
func isFileNotFound() -> Bool {
|
||||||
(self as NSError).code == NSFileReadNoSuchFileError
|
(self as NSError).isFileNotFound() || (self as NSError).underlyingErrors.contains(where: { $0.isFileNotFound() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,10 +50,13 @@ enum RuntimeError : Error {
|
|||||||
case VMConfigurationError(_ message: String)
|
case VMConfigurationError(_ message: String)
|
||||||
case VMDoesNotExist(name: String)
|
case VMDoesNotExist(name: String)
|
||||||
case VMMissingFiles(_ message: String)
|
case VMMissingFiles(_ message: String)
|
||||||
case VMNotRunning(_ message: String)
|
case VMIsRunning(_ name: String)
|
||||||
|
case VMNotRunning(_ name: String)
|
||||||
case VMAlreadyRunning(_ message: String)
|
case VMAlreadyRunning(_ message: String)
|
||||||
case NoIPAddressFound(_ message: String)
|
case NoIPAddressFound(_ message: String)
|
||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
|
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||||
|
case InvalidDiskSize(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
case FailedToParseRemoteName(_ message: String)
|
case FailedToParseRemoteName(_ message: String)
|
||||||
@@ -58,7 +67,9 @@ 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 OCIUnsupportedDiskFormat(_ format: String)
|
||||||
case SuspendFailed(_ message: String)
|
case SuspendFailed(_ message: String)
|
||||||
|
case PullFailed(_ message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol HasExitCode {
|
protocol HasExitCode {
|
||||||
@@ -74,14 +85,20 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "the specified VM \"\(name)\" does not exist"
|
return "the specified VM \"\(name)\" does not exist"
|
||||||
case .VMMissingFiles(let message):
|
case .VMMissingFiles(let message):
|
||||||
return message
|
return message
|
||||||
case .VMNotRunning(let message):
|
case .VMIsRunning(let name):
|
||||||
return message
|
return "VM \"\(name)\" is running"
|
||||||
|
case .VMNotRunning(let name):
|
||||||
|
return "VM \"\(name)\" is not running"
|
||||||
case .VMAlreadyRunning(let message):
|
case .VMAlreadyRunning(let message):
|
||||||
return message
|
return message
|
||||||
case .NoIPAddressFound(let message):
|
case .NoIPAddressFound(let message):
|
||||||
return message
|
return message
|
||||||
case .DiskAlreadyInUse(let message):
|
case .DiskAlreadyInUse(let message):
|
||||||
return message
|
return message
|
||||||
|
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||||
|
return "failed to open block device \(path): \(explanation)"
|
||||||
|
case .InvalidDiskSize(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
@@ -102,8 +119,12 @@ 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 .OCIUnsupportedDiskFormat(let format):
|
||||||
|
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||||
case .SuspendFailed(let message):
|
case .SuspendFailed(let message):
|
||||||
return "Failed to suspend the VM: \(message)"
|
return "Failed to suspend the VM: \(message)"
|
||||||
|
case .PullFailed(let message):
|
||||||
|
return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +132,8 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
extension RuntimeError : HasExitCode {
|
extension RuntimeError : HasExitCode {
|
||||||
var exitCode: Int32 {
|
var exitCode: Int32 {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .VMDoesNotExist:
|
||||||
|
return 2
|
||||||
case .VMNotRunning:
|
case .VMNotRunning:
|
||||||
return 2
|
return 2
|
||||||
case .VMAlreadyRunning:
|
case .VMAlreadyRunning:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ class VMStorageLocal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ name: String) throws {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func list() throws -> [(String, VMDirectory)] {
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import Retry
|
||||||
|
|
||||||
class VMStorageOCI: PrunableStorage {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
@@ -112,6 +113,10 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the relative VM's path at the last component
|
||||||
|
// and figure out which character should be used
|
||||||
|
// to join them together, either ":" for tags or
|
||||||
|
// "@" for hashes
|
||||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@@ -122,6 +127,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
name = parts.joined(separator: "@")
|
name = parts.joined(separator: "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the percent-encoding, if any
|
||||||
|
name = percentDecode(name)
|
||||||
|
|
||||||
result.append((name, vmDir, isSymlink))
|
result.append((name, vmDir, isSymlink))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,9 +140,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
@@ -170,7 +178,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)
|
||||||
@@ -188,7 +196,23 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||||
|
var localLayerCache: LocalLayerCache? = nil
|
||||||
|
|
||||||
|
if name.reference.type == .Tag,
|
||||||
|
let vmDir = try? open(name),
|
||||||
|
let digest = try? digest(name),
|
||||||
|
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||||
|
localLayerCache = try LocalLayerCache(vmDir.diskURL, manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache)
|
||||||
|
} recoverFromFailure: { error in
|
||||||
|
print("Error: \(error.localizedDescription)")
|
||||||
|
print("Attempting to re-try...")
|
||||||
|
|
||||||
|
return .retry
|
||||||
|
}
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
transaction.finish()
|
transaction.finish()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
@@ -219,9 +243,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
try? FileManager.default.removeItem(at: vmURL(from))
|
||||||
try FileManager.default.removeItem(at: vmURL(from))
|
|
||||||
}
|
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||||
|
|
||||||
@@ -233,7 +255,7 @@ extension URL {
|
|||||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||||
var result: URL = self
|
var result: URL = self
|
||||||
|
|
||||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
for pathComponent in (percentEncode(name.host) + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||||
result = result.appendingPathComponent(String(pathComponent))
|
result = result.appendingPathComponent(String(pathComponent))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +263,23 @@ extension URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendingHost(_ name: RemoteName) -> URL {
|
func appendingHost(_ name: RemoteName) -> URL {
|
||||||
self.appendingPathComponent(name.host, isDirectory: true)
|
self.appendingPathComponent(percentEncode(name.host), isDirectory: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Work around a pretty inane Swift's URL behavior where calling
|
||||||
|
// appendingPathComponent() or deletingLastPathComponent() on a
|
||||||
|
// URL like URL(filePath: "example.com:8080") (note the "filePath")
|
||||||
|
// will flip its isFileURL from "true" to "false" and discard its
|
||||||
|
// absolute path infromation (if any).
|
||||||
|
//
|
||||||
|
// The same kind of operations won't do anything to a URL like
|
||||||
|
// URL(filePath: "127.0.0.1:8080"), which makes things even more
|
||||||
|
// ridiculous.
|
||||||
|
private func percentEncode(_ s: String) -> String {
|
||||||
|
return s.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: ":").inverted)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentDecode(_ s: String) -> String {
|
||||||
|
s.removingPercentEncoding!
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
|||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
while true {
|
while true {
|
||||||
// Port is 0 shortly after start(),
|
// Port is 0 shortly after start(),
|
||||||
// but will be initialized later
|
// but will be initialized later
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
|||||||
self.vmConfig = vmConfig
|
self.vmConfig = vmConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||||
|
|
||||||
if let ip = ip {
|
if let ip = ip {
|
||||||
return URL(string: "vnc://\(ip)")!
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol VNC {
|
protocol VNC {
|
||||||
func waitForURL() async throws -> URL
|
func waitForURL(netBridged: Bool) async throws -> URL
|
||||||
func stop() throws
|
func stop() throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
final class DirectoryShareTests: XCTestCase {
|
final class DirectoryShareTests: XCTestCase {
|
||||||
func testNamedParsing() throws {
|
func testNamedParsing() throws {
|
||||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||||
@@ -8,25 +10,65 @@ final class DirectoryShareTests: XCTestCase {
|
|||||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
XCTAssertFalse(share.readOnly)
|
XCTAssertFalse(share.readOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNamedReadOnlyParsing() throws {
|
func testNamedReadOnlyParsing() throws {
|
||||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||||
XCTAssertEqual(share.name, "build")
|
XCTAssertEqual(share.name, "build")
|
||||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
XCTAssertTrue(share.readOnly)
|
XCTAssertTrue(share.readOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOptionalNameParsing() throws {
|
func testOptionalNameParsing() throws {
|
||||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||||
XCTAssertNil(share.name)
|
XCTAssertNil(share.name)
|
||||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
XCTAssertFalse(share.readOnly)
|
XCTAssertFalse(share.readOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOptionalNameReadOnlyParsing() throws {
|
func testOptionalNameReadOnlyParsing() throws {
|
||||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||||
XCTAssertNil(share.name)
|
XCTAssertNil(share.name)
|
||||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
XCTAssertTrue(share.readOnly)
|
XCTAssertTrue(share.readOnly)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testMountTagParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
XCTAssertEqual(share.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar")
|
||||||
|
XCTAssertNil(roShare.name)
|
||||||
|
XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(roShare.readOnly)
|
||||||
|
XCTAssertEqual(roShare.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro")
|
||||||
|
XCTAssertNil(inverseRoShare.name)
|
||||||
|
XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(inverseRoShare.readOnly)
|
||||||
|
XCTAssertEqual(inverseRoShare.mountTag, "foo-bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testURL() throws {
|
||||||
|
let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz")
|
||||||
|
XCTAssertNil(archiveWithoutNameOrOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertFalse(archiveWithoutNameOrOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
|
||||||
|
|
||||||
|
let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertNil(archiveWithOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithOptions.mountTag, "sometag")
|
||||||
|
|
||||||
|
let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.name, "somename")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DockerConfigTests: XCTestCase {
|
||||||
|
func testHelpers() throws {
|
||||||
|
let config = DockerConfig(credHelpers: [
|
||||||
|
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
|
||||||
|
"gcr.io": "gcloud"
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class LayerizerTests: XCTestCase {
|
||||||
|
var registryRunner: RegistryRunner?
|
||||||
|
|
||||||
|
var registry: Registry {
|
||||||
|
registryRunner!.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
try await super.setUp()
|
||||||
|
|
||||||
|
do {
|
||||||
|
registryRunner = try await RegistryRunner()
|
||||||
|
} catch {
|
||||||
|
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
try await super.tearDown()
|
||||||
|
|
||||||
|
registryRunner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV1() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
|
||||||
|
|
||||||
|
print("pulling disk...")
|
||||||
|
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV2() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, progress: Progress())
|
||||||
|
|
||||||
|
print("pulling disk...")
|
||||||
|
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileWithRandomData(sizeBytes: Int) throws -> URL {
|
||||||
|
let devUrandom = try FileHandle(forReadingFrom: URL(filePath: "/dev/urandom"))
|
||||||
|
|
||||||
|
let temporaryFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
FileManager.default.createFile(atPath: temporaryFileURL.path, contents: nil)
|
||||||
|
let temporaryFile = try FileHandle(forWritingTo: temporaryFileURL)
|
||||||
|
|
||||||
|
var remainingBytes = sizeBytes
|
||||||
|
|
||||||
|
while remainingBytes > 0 {
|
||||||
|
let randomData = try devUrandom.read(upToCount: min(64 * 1024 * 1024, remainingBytes))!
|
||||||
|
remainingBytes -= randomData.count
|
||||||
|
try temporaryFile.write(contentsOf: randomData)
|
||||||
|
}
|
||||||
|
|
||||||
|
try devUrandom.close()
|
||||||
|
|
||||||
|
try temporaryFile.close()
|
||||||
|
|
||||||
|
return temporaryFileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
|
final class LeaseTests: XCTestCase {
|
||||||
|
func testCorrectTimezone() throws {
|
||||||
|
let lease = Lease(fromRawLease: [
|
||||||
|
"hw_address": "1,11:22:33:44:55:66",
|
||||||
|
"ip_address": "1.2.3.4",
|
||||||
|
"lease": "0x6565da9e",
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertNotNil(lease)
|
||||||
|
XCTAssertEqual(lease!.expiresAt.toISO(), "2023-11-28T12:18:38Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
final class LeasesTests: XCTestCase {
|
||||||
|
func testNoExpired() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=1.2.3.4
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("1.2.3.4"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDuplicateYetNotExpiredLeases() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.1
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.2
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 5.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("192.168.64.1"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleEntries() throws {
|
func testMultipleEntries() throws {
|
||||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import XCTest
|
|||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.token, "some token")
|
XCTAssertEqual(tokenResponse.token, "some token")
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExpirationBasic() throws {
|
func testExpirationBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
root = true
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable-all: true
|
||||||
|
|
||||||
|
disable:
|
||||||
|
# Messages like "struct of size 104 bytes could be of size 96 bytes" from a package
|
||||||
|
# that was last updated 2 years ago[1] are barely helpful.
|
||||||
|
#
|
||||||
|
# After all, we're writing the code for other people, so let's trust the compiler here (that's
|
||||||
|
# constantly evolving compared to this linter) and revisit this if memory usage becomes a problem.
|
||||||
|
#
|
||||||
|
# [1]: https://github.com/mdempsky/maligned/commit/6e39bd26a8c8b58c5a22129593044655a9e25959
|
||||||
|
- maligned
|
||||||
|
|
||||||
|
# We don't have high-performance requirements at this moment, so sacrificing
|
||||||
|
# the code readability for marginal performance gains is not worth it.
|
||||||
|
- prealloc
|
||||||
|
|
||||||
|
# New linters that require a lot of codebase churn and noise, but perhaps we can enable them in the future.
|
||||||
|
- nlreturn
|
||||||
|
- wrapcheck
|
||||||
|
- errorlint
|
||||||
|
|
||||||
|
# Unfortunately, we use globals due to how spf13/cobra works.
|
||||||
|
- gochecknoglobals
|
||||||
|
|
||||||
|
# That's fine that some Proto objects don't have all fields initialized
|
||||||
|
- exhaustivestruct
|
||||||
|
|
||||||
|
# Style linters that are total nuts.
|
||||||
|
- wsl
|
||||||
|
- gofumpt
|
||||||
|
- goimports
|
||||||
|
- funlen
|
||||||
|
|
||||||
|
# This conflicts with the Protocol Buffers Version 3 design,
|
||||||
|
# which is largely based on default values for struct fields.
|
||||||
|
- exhaustivestruct
|
||||||
|
|
||||||
|
# Enough parallelism for now.
|
||||||
|
- paralleltest
|
||||||
|
|
||||||
|
# Ill-based assumptions about identifiers like fmt.Println without taking context into account.
|
||||||
|
- forbidigo
|
||||||
|
|
||||||
|
# Advantages of using t.Helper() are too small to waste developer's cognitive stamina on it.
|
||||||
|
- thelper
|
||||||
|
|
||||||
|
# Too restrictive defaults, plus there's already a gocyclo linter in place.
|
||||||
|
- cyclop
|
||||||
|
|
||||||
|
# Gives false positives for textbook examples[1][2]
|
||||||
|
# [1]: https://github.com/charithe/durationcheck/issues/7
|
||||||
|
# [2]: https://golang.org/pkg/time/ (see "To convert an integer number of units to a Duration, multiply:")
|
||||||
|
- durationcheck
|
||||||
|
|
||||||
|
# No way to disable the "exported" check for the whole project[1]
|
||||||
|
# [1]: https://github.com/mgechev/revive/issues/244#issuecomment-560512162
|
||||||
|
- revive
|
||||||
|
|
||||||
|
# Unfortunately too much false-positives, e.g. for a 0700 umask or number 10 when using strconv.FormatInt()
|
||||||
|
- gomnd
|
||||||
|
|
||||||
|
# Needs package whitelists
|
||||||
|
- depguard
|
||||||
|
|
||||||
|
# Generates absolutely useless errors, e.g.
|
||||||
|
# "string `.yml` has 3 occurrences, make it a constant"
|
||||||
|
- goconst
|
||||||
|
|
||||||
|
# It's OK to not sort imports
|
||||||
|
- gci
|
||||||
|
|
||||||
|
# It's OK to not initialize some struct fields
|
||||||
|
- exhaustruct
|
||||||
|
|
||||||
|
# This is not a library, so it's OK to use dynamic errors
|
||||||
|
- goerr113
|
||||||
|
|
||||||
|
# fmt.Sprintf() looks a bit nicer than string addition
|
||||||
|
- perfsprint
|
||||||
|
|
||||||
|
# We can control this ourselves
|
||||||
|
- varnamelen
|
||||||
|
- contextcheck
|
||||||
|
|
||||||
|
issues:
|
||||||
|
# Don't hide multiple issues that belong to one class since GitHub annotations can handle them all nicely.
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Benchmark
|
||||||
|
|
||||||
|
Tart comes with a Golang-based benchmarking utility that allows one to easily compare host and guest performance.
|
||||||
|
|
||||||
|
Currently, only Flexible I/O tester workloads are supported. To run them, [install Golang](https://go.dev/) and run the following command from this (`benchmark/`) directory:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go run cmd/main.go fio
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also enable the debugging output to diagnose issues:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
go run cmd/main.go fio --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Host:
|
||||||
|
|
||||||
|
* Hardware: Mac mini (Apple M2 Pro, 8 performance and 4 efficiency cores, 32 GB RAM, `Mac14,12`)
|
||||||
|
* OS: macOS Sonoma 14.4.1
|
||||||
|
|
||||||
|
Guest:
|
||||||
|
|
||||||
|
* Hardware: [Virtualization.Framework](https://developer.apple.com/documentation/virtualization)
|
||||||
|
* OS: macOS Sonoma 14.4.1
|
||||||
|
|
||||||
|
```
|
||||||
|
Name Executor Bandwidth I/O operations
|
||||||
|
Random writing of 1MB local 2.6 GB/s 649.35 kIOPS
|
||||||
|
Random writing of 1MB Tart 2.5 GB/s 620.22 kIOPS
|
||||||
|
Random writing of 10MB local 2.6 GB/s 651.74 kIOPS
|
||||||
|
Random writing of 10MB Tart 2.5 GB/s 615.52 kIOPS
|
||||||
|
Random writing of 100MB local 1.9 GB/s 481.51 kIOPS
|
||||||
|
Random writing of 100MB Tart 2.0 GB/s 493.31 kIOPS
|
||||||
|
Random writing of 1000MB local 1.7 GB/s 414.89 kIOPS
|
||||||
|
Random writing of 1000MB Tart 1.1 GB/s 287.4 kIOPS
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/command"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Set up a signal-interruptible context
|
||||||
|
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
|
||||||
|
// Run the root command
|
||||||
|
if err := command.NewCommand().ExecuteContext(ctx); err != nil {
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module github.com/cirruslabs/tart/benchmark
|
||||||
|
|
||||||
|
go 1.22.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/avast/retry-go/v4 v4.5.1
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gosuri/uitable v0.0.4
|
||||||
|
github.com/spf13/cobra v1.8.0
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
|
golang.org/x/sys v0.18.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
github.com/avast/retry-go/v4 v4.5.1 h1:AxIx0HGi4VZ3I02jr78j5lZ3M6x1E0Ivxa6b0pUUh7o=
|
||||||
|
github.com/avast/retry-go/v4 v4.5.1/go.mod h1:/sipNsvNB3RRuT5iNcb6h73nw3IBmXJ/H3XrCQYSOpc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY=
|
||||||
|
github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||||
|
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||||
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
|
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||||
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||||
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
|
||||||
|
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
type Benchmark struct {
|
||||||
|
Name string
|
||||||
|
Command string
|
||||||
|
}
|
||||||
|
|
||||||
|
var benchmarks = []Benchmark{
|
||||||
|
{
|
||||||
|
Name: "Random writing of 1MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 1MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 10MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 10MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 100MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 100MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Random writing of 1000MB",
|
||||||
|
Command: "fio --rw randwrite --runtime 30 --time_based --unlink 1 --output-format json " +
|
||||||
|
"--size 1000MB --name unnamed --numjobs 1 --iodepth 1 --end_fsync 1",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/gosuri/uitable"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var debug bool
|
||||||
|
|
||||||
|
func NewCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "fio",
|
||||||
|
Short: "run Flexible I/O tester (fio) benchmarks",
|
||||||
|
RunE: run,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(cmd *cobra.Command, args []string) error {
|
||||||
|
config := zap.NewProductionConfig()
|
||||||
|
if debug {
|
||||||
|
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
|
||||||
|
}
|
||||||
|
logger, err := config.Build()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = logger.Sync()
|
||||||
|
}()
|
||||||
|
|
||||||
|
executors, err := initializeExecutors(cmd.Context(), logger)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
errs := []error{err}
|
||||||
|
|
||||||
|
for _, executor := range executors {
|
||||||
|
if err := executor.Close(); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to close executor %s: %w", executor.Name(), err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = errors.Join(errs...)
|
||||||
|
}()
|
||||||
|
|
||||||
|
table := uitable.New()
|
||||||
|
table.AddRow("Name", "Executor", "Bandwidth", "I/O operations")
|
||||||
|
|
||||||
|
for _, benchmark := range benchmarks {
|
||||||
|
for _, executor := range executors {
|
||||||
|
logger.Sugar().Infof("running benchmark %q on %s executor", benchmark.Name, executor.Name())
|
||||||
|
|
||||||
|
stdout, err := executor.Run(cmd.Context(), benchmark.Command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var fioResult Result
|
||||||
|
|
||||||
|
if err := json.Unmarshal(stdout, &fioResult); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fioResult.Jobs) != 1 {
|
||||||
|
return fmt.Errorf("expected exactly 1 job from fio's JSON output, got %d",
|
||||||
|
len(fioResult.Jobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
job := fioResult.Jobs[0]
|
||||||
|
|
||||||
|
writeBandwidth := humanize.Bytes(uint64(job.Write.BW)*humanize.KByte) + "/s"
|
||||||
|
writeIOPS := humanize.SIWithDigits(job.Write.IOPS, 2, "IOPS")
|
||||||
|
|
||||||
|
logger.Sugar().Infof("write bandwidth: %s, write IOPS: %s\n", writeBandwidth, writeIOPS)
|
||||||
|
|
||||||
|
table.AddRow(benchmark.Name, executor.Name(), writeBandwidth, writeIOPS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(table.String())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeExecutors(ctx context.Context, logger *zap.Logger) ([]executor.Executor, error) {
|
||||||
|
var result []executor.Executor
|
||||||
|
|
||||||
|
logger.Info("initializing local executor")
|
||||||
|
|
||||||
|
local, err := local.New(logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, local)
|
||||||
|
|
||||||
|
logger.Info("local executor initialized")
|
||||||
|
|
||||||
|
logger.Info("initializing Tart executor")
|
||||||
|
|
||||||
|
tart, err := tart.New(ctx, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, tart)
|
||||||
|
|
||||||
|
logger.Info("Tart executor initialized")
|
||||||
|
|
||||||
|
for _, executor := range result {
|
||||||
|
logger.Sugar().Infof("installing Flexible I/O tester (fio) on %s executor", executor.Name())
|
||||||
|
|
||||||
|
if _, err := executor.Run(ctx, "brew install fio"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package fio
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Jobs []Job `json:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
Name string `json:"jobname"`
|
||||||
|
Write Write `json:"write"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Write struct {
|
||||||
|
BW float64 `json:"bw"`
|
||||||
|
IOPS float64 `json:"iops"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/command/fio"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "benchmark",
|
||||||
|
SilenceUsage: true,
|
||||||
|
SilenceErrors: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
fio.NewCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Executor interface {
|
||||||
|
Name() string
|
||||||
|
Run(ctx context.Context, command string) ([]byte, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapio"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Local struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logger *zap.Logger) (*Local, error) {
|
||||||
|
return &Local{
|
||||||
|
logger: logger,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (local *Local) Name() string {
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (local *Local) Run(ctx context.Context, command string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "zsh", "-c", command)
|
||||||
|
|
||||||
|
loggerWriter := &zapio.Writer{Log: local.logger, Level: zap.DebugLevel}
|
||||||
|
stdoutBuf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||||
|
cmd.Stderr = loggerWriter
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
return stdoutBuf.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (local *Local) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package local_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/local"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocal(t *testing.T) {
|
||||||
|
local, err := local.New(zap.NewNop())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := local.Run(context.Background(), "echo \"this is a test\"")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "this is a test\n", string(output))
|
||||||
|
|
||||||
|
require.NoError(t, local.Close())
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package tart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapio"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tartBinaryName = "tart"
|
||||||
|
|
||||||
|
func Cmd(ctx context.Context, logger *zap.Logger, args ...string) error {
|
||||||
|
_, err := CmdWithOutput(ctx, logger, args...)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CmdWithOutput(ctx context.Context, logger *zap.Logger, args ...string) (string, error) {
|
||||||
|
logger.Sugar().Debugf("running %s %s", tartBinaryName, strings.Join(args, " "))
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, tartBinaryName, args...)
|
||||||
|
|
||||||
|
loggerWriter := &zapio.Writer{Log: logger, Level: zap.DebugLevel}
|
||||||
|
stdoutBuf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
cmd.Stdout = io.MultiWriter(stdoutBuf, loggerWriter)
|
||||||
|
cmd.Stderr = loggerWriter
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
return stdoutBuf.String(), err
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package tart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/avast/retry-go/v4"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const baseImage = "ghcr.io/cirruslabs/macos-sonoma-base:latest"
|
||||||
|
|
||||||
|
type Tart struct {
|
||||||
|
vmRunCancel context.CancelFunc
|
||||||
|
vmName string
|
||||||
|
sshClient *ssh.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(ctx context.Context, logger *zap.Logger) (*Tart, error) {
|
||||||
|
tart := &Tart{
|
||||||
|
vmName: fmt.Sprintf("tart-benchmark-%s", uuid.NewString()),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Cmd(ctx, tart.logger, "pull", baseImage); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Cmd(ctx, tart.logger, "clone", baseImage, tart.vmName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vmRunCtx, vmRunCancel := context.WithCancel(ctx)
|
||||||
|
tart.vmRunCancel = vmRunCancel
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_ = Cmd(vmRunCtx, tart.logger, "run", "--no-graphics", tart.vmName)
|
||||||
|
}()
|
||||||
|
|
||||||
|
ip, err := CmdWithOutput(ctx, tart.logger, "ip", "--wait", "60", tart.vmName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, tart.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = retry.Do(func() error {
|
||||||
|
dialer := net.Dialer{
|
||||||
|
Timeout: 1 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:22", strings.TrimSpace(ip))
|
||||||
|
|
||||||
|
netConn, err := dialer.DialContext(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sshConn, chans, reqs, err := ssh.NewClientConn(netConn, addr, &ssh.ClientConfig{
|
||||||
|
User: "admin",
|
||||||
|
Auth: []ssh.AuthMethod{
|
||||||
|
ssh.Password("admin"),
|
||||||
|
},
|
||||||
|
HostKeyCallback: func(_ string, _ net.Addr, _ ssh.PublicKey) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tart.sshClient = ssh.NewClient(sshConn, chans, reqs)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, retry.RetryIf(func(err error) bool {
|
||||||
|
return !errors.Is(err, context.Canceled)
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, tart.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return tart, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tart *Tart) Name() string {
|
||||||
|
return "Tart"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tart *Tart) Run(ctx context.Context, command string) ([]byte, error) {
|
||||||
|
sshSession, err := tart.sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work around x/crypto/ssh not being context.Context-friendly (e.g. https://github.com/golang/go/issues/20288)
|
||||||
|
monitorCtx, monitorCancel := context.WithCancel(ctx)
|
||||||
|
go func() {
|
||||||
|
<-monitorCtx.Done()
|
||||||
|
_ = sshSession.Close()
|
||||||
|
}()
|
||||||
|
defer monitorCancel()
|
||||||
|
|
||||||
|
stdoutBuf := &bytes.Buffer{}
|
||||||
|
|
||||||
|
sshSession.Stdin = bytes.NewBufferString(command)
|
||||||
|
sshSession.Stdout = stdoutBuf
|
||||||
|
|
||||||
|
if err := sshSession.Shell(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sshSession.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdoutBuf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tart *Tart) Close() error {
|
||||||
|
if tart.sshClient != nil {
|
||||||
|
_ = tart.sshClient.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
tart.vmRunCancel()
|
||||||
|
_ = Cmd(context.Background(), tart.logger, "delete", tart.vmName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package tart_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/cirruslabs/tart/benchmark/internal/executor/tart"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tart, err := tart.New(ctx, zap.NewNop())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := tart.Run(ctx, "echo \"this is a test\"")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "this is a test\n", string(output))
|
||||||
|
|
||||||
|
require.NoError(t, tart.Close())
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"default": true
|
||||||
|
"MD002": false # First heading should be a top level heading
|
||||||
|
"MD007": # Unordered list indentation
|
||||||
|
indent: 4
|
||||||
|
"MD009": false # Trailing spaces
|
||||||
|
"MD013": false # Line length
|
||||||
|
"MD025": false # Multiple top level headings in the same document
|
||||||
|
"MD026": false # Trailing punctuation in heading
|
||||||
|
"MD033": false # Inline HTML
|
||||||
|
"MD041": false # First line in file should be a top level heading
|
||||||
|
"MD045": false # OK not to have a description for an image
|
||||||
|
"MD046": false # Code block style [Expected: fenced; Actual: indented]
|
||||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -1,8 +1,9 @@
|
|||||||
edigaryev:
|
authors:
|
||||||
name: Nikolay Edigaryev
|
edigaryev:
|
||||||
description: Creator
|
name: Nikolay Edigaryev
|
||||||
avatar: https://github.com/edigaryev.png
|
description: Creator
|
||||||
fkorotkov:
|
avatar: https://github.com/edigaryev.png
|
||||||
name: Fedor Korotkov
|
fkorotkov:
|
||||||
description: Creator
|
name: Fedor Korotkov
|
||||||
avatar: https://github.com/fkorotkov.png
|
description: Creator
|
||||||
|
avatar: https://github.com/fkorotkov.png
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 538 KiB |
@@ -62,7 +62,7 @@ and before reaching the 100 CPU cores limit is royalty-free and does not have th
|
|||||||
|
|
||||||
When an organization surpasses the 100 CPU cores limit, they will be required to obtain a [Gold Tier License](/licensing#license-tiers),
|
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 Tier License](/licensing#license-tiers)
|
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 Tier License](/licensing#license-tiers)
|
(\$3000 per month) will be required, and for organizations that exceed 3000 CPU cores, a custom [Diamond Tier License](/licensing#license-tiers)
|
||||||
(\$1 per core per month) will be necessary. **All paid license tiers 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?
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ On bootstrap, each Orchard worker establishes a `Watch()` RPC stream and waits f
|
|||||||
|
|
||||||
Once `PortForward` instruction is received, the worker connects to the specified VM and port locally and opens a new `PortForward()` RPC stream with the controller, carrying the unique `session` identifier in the gRPC metadata to help distinguish several port forwarding requests.
|
Once `PortForward` instruction is received, the worker connects to the specified VM and port locally and opens a new `PortForward()` RPC stream with the controller, carrying the unique `session` identifier in the gRPC metadata to help distinguish several port forwarding requests.
|
||||||
|
|
||||||
We’re using a pretty ingenious Golang package that turns any gRPC stream into a `net.Conn`: https://github.com/mitchellh/go-grpc-net-conn. This allows us to abstract from the gRPC details and simply proxy two `net.Conns`, thus providing the port forwarding functionality.
|
We’re using a pretty ingenious [Golang package that turns any gRPC stream into a `net.Conn`](https://github.com/mitchellh/go-grpc-net-conn). This allows us to abstract from the gRPC details and simply proxy two `net.Conns`, thus providing the port forwarding functionality.
|
||||||
|
|
||||||
We’ve also initially considered using [Yamux](https://github.com/hashicorp/yamux) to only keep a single connection with each worker, however, that involves the burden of dealing with flow control and potential implementation bugs associated with it, so we’ve decided to simply open an additional connection for each port forwarding session and let the OS deal with it.
|
We’ve also initially considered using [Yamux](https://github.com/hashicorp/yamux) to only keep a single connection with each worker, however, that involves the burden of dealing with flow control and potential implementation bugs associated with it, so we’ve decided to simply open an additional connection for each port forwarding session and let the OS deal with it.
|
||||||
|
|
||||||
@@ -74,25 +74,25 @@ Secondly, we’ve exposed three commands in the Orchard CLI that all use this en
|
|||||||
|
|
||||||
Opens a TCP port locally and forwards everything sent to it to the specified VM (and vice versa).
|
Opens a TCP port locally and forwards everything sent to it to the specified VM (and vice versa).
|
||||||
|
|
||||||
For example, `orchard port-forward vm ventura-builder 2222:22` will forward traffic from the local TCP port `2222` to the `ventura-builder` VM’s TCP port `22`.
|
For example, `orchard port-forward vm sonoma-builder 2222:22` will forward traffic from the local TCP port `2222` to the `ventura-builder` VM’s TCP port `22`.
|
||||||
|
|
||||||
### `orchard ssh`
|
### `orchard ssh`
|
||||||
|
|
||||||
Connects to the specified VM on the default SSH port `22`, optionally only launching a command (if specified), similarly to what the official OpenSSH client does.
|
Connects to the specified VM on the default SSH port `22`, optionally only launching a command (if specified), similarly to what the official OpenSSH client does.
|
||||||
|
|
||||||
For example, `orchard ssh vm ventura-builder` will open an interactive session with the `ventura-builder` VM.
|
For example, `orchard ssh vm sonoma-builder` will open an interactive session with the `ventura-builder` VM.
|
||||||
|
|
||||||
You can also send local scripts for execution by utilizing redirection:
|
You can also send local scripts for execution by utilizing redirection:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
orchard ssh vm ventura-builder 'sh -s' < script.sh
|
orchard ssh vm sonoma-builder 'sh -s' < script.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
### `orchard vnc`
|
### `orchard vnc`
|
||||||
|
|
||||||
Establishes a port forwarding to the specified VM’s default VNC port `5900` and opens the default macOS Screen Sharing app.
|
Establishes a port forwarding to the specified VM’s default VNC port `5900` and opens the default macOS Screen Sharing app.
|
||||||
|
|
||||||
For example, `orchard vnc vm ventura-builder` will establish a port-forwarding to the `ventura-builder` VM's port `5900` under the hood and launch macOS Screen Sharing app.
|
For example, `orchard vnc vm sonoma-builder` will establish a port-forwarding to the `ventura-builder` VM's port `5900` under the hood and launch macOS Screen Sharing app.
|
||||||
|
|
||||||
Note that the SSH and VNC commands expect the VM resource to specify credentials in it’s definition (can be done via `orchard create vm`), and will otherwise fall back to the credentials specified by `--username` and `--password`, or if none specified — to de-facto standard of `admin:admin` credentials.
|
Note that the SSH and VNC commands expect the VM resource to specify credentials in it’s definition (can be done via `orchard create vm`), and will otherwise fall back to the credentials specified by `--username` and `--password`, or if none specified — to de-facto standard of `admin:admin` credentials.
|
||||||
|
|
||||||
|
|||||||