Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fb8069edd | ||
|
|
c78c89e274 | ||
|
|
770220f905 | ||
|
|
768d1f9bad | ||
|
|
d49ed46439 | ||
|
|
b52a857698 | ||
|
|
3bf0bb22f3 | ||
|
|
accbd0cb33 | ||
|
|
c0b20932c7 | ||
|
|
3694af946c | ||
|
|
dbf711a6c9 | ||
|
|
b9f24a40c1 | ||
|
|
10c6ace671 | ||
|
|
b98e23956b | ||
|
|
ce23f9c2a7 | ||
|
|
3da91e6518 | ||
|
|
7046886713 | ||
|
|
3fde7d08dd | ||
|
|
227301436c | ||
|
|
106eb5a2c8 | ||
|
|
10bf706653 | ||
|
|
ff928ad77d | ||
|
|
33b5cfe2ed | ||
|
|
3892cdb00d | ||
|
|
5f2199ef3e | ||
|
|
3f26baa341 | ||
|
|
06cae1296e | ||
|
|
1b81b12760 | ||
|
|
4ed73bc775 | ||
|
|
2dc25ce478 | ||
|
|
1e74e268a5 | ||
|
|
bff344fb7f | ||
|
|
ababe8cefc | ||
|
|
ea5313698e | ||
|
|
679289d7ab | ||
|
|
5eccdf7412 | ||
|
|
63e3235d91 | ||
|
|
a760a431c3 | ||
|
|
bf5081b3d9 | ||
|
|
017592075f | ||
|
|
84e1ae2b38 | ||
|
|
d50e113300 | ||
|
|
fce52f1514 | ||
|
|
9484b8b2c9 | ||
|
|
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 |
@@ -7,6 +7,8 @@ export VERSION="${CIRRUS_TAG:-0}"
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||
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 \
|
||||
--scripts .ci/pkg/scripts \
|
||||
--install-location "/Library/Application Support/Tart" \
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
set -e
|
||||
|
||||
# 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/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 "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
TMPFILE=$(mktemp)
|
||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use_compute_credits: true
|
||||
|
||||
env:
|
||||
XCODE_TAG: 15
|
||||
|
||||
task:
|
||||
name: Test on Sonoma
|
||||
alias: test
|
||||
@@ -11,11 +8,11 @@ task:
|
||||
name: dev-mini
|
||||
resources:
|
||||
tart-vms: 1
|
||||
build_script:
|
||||
- swift build
|
||||
test_script:
|
||||
- swift test
|
||||
integration_test_script:
|
||||
# Build Tart
|
||||
- swift build
|
||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||
# Run integration tests
|
||||
@@ -41,7 +38,7 @@ task:
|
||||
name: Lint
|
||||
alias: lint
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
lint_script:
|
||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||
always:
|
||||
@@ -51,14 +48,18 @@ task:
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
name: Build
|
||||
env:
|
||||
matrix:
|
||||
BUILD_ARCH: arm64
|
||||
BUILD_ARCH: x86_64
|
||||
name: Build ($BUILD_ARCH)
|
||||
alias: build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||
|
||||
task:
|
||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||
@@ -67,7 +68,7 @@ task:
|
||||
- lint
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -83,13 +84,13 @@ task:
|
||||
- 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
|
||||
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
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
|
||||
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
|
||||
always:
|
||||
dist_artifacts:
|
||||
path: "dist/*"
|
||||
@@ -102,7 +103,7 @@ task:
|
||||
- test
|
||||
- build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-sonoma-xcode:$XCODE_TAG
|
||||
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
@@ -110,7 +111,7 @@ task:
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
SENTRY_ORG: cirrus-labs
|
||||
SENTRY_PROJECT: persistent-workers
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
|
||||
@@ -16,3 +16,6 @@ dist/
|
||||
|
||||
# mkdocs
|
||||
.cache
|
||||
|
||||
# mkdocs-material
|
||||
site
|
||||
|
||||
@@ -3,27 +3,35 @@ project_name: tart
|
||||
before:
|
||||
hooks:
|
||||
- .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
|
||||
- mkdir -p tart.app/Contents/MacOS
|
||||
- cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
|
||||
|
||||
builds:
|
||||
- builder: prebuilt
|
||||
- id: tart
|
||||
builder: prebuilt
|
||||
goamd64: [v1]
|
||||
goos:
|
||||
- darwin
|
||||
goarch:
|
||||
- arm64
|
||||
- amd64
|
||||
binary: tart.app/Contents/MacOS/tart
|
||||
prebuilt:
|
||||
path: tart.app/Contents/MacOS/tart
|
||||
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||
|
||||
archives:
|
||||
- name_template: "{{ .ProjectName }}"
|
||||
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||
files:
|
||||
- src: Resources/embedded.provisionprofile
|
||||
dst: tart.app/Contents
|
||||
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
|
||||
|
||||
release:
|
||||
@@ -31,24 +39,19 @@ release:
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
tap:
|
||||
repository:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
license: "Fair Source"
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
description: Run macOS and Linux VMs on Apple Hardware
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
install: |
|
||||
libexec.install Dir["*"]
|
||||
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: |
|
||||
depends_on :macos => :ventura
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Profiling Tart
|
||||
|
||||
## Using `time(1)`
|
||||
|
||||
Perhaps, the easiest, but not the most comprehensive way to tell what's going on with Tart is to use the [`time(1)`](https://ss64.com/mac/time.html) command.
|
||||
|
||||
In the example below, you will run `tart pull` via `time(1)` to gather generalized CPU, I/O and memory usage metrics:
|
||||
|
||||
```shell
|
||||
/usr/bin/time -l tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
```
|
||||
|
||||
**Note:** you need to specify a full path to `time(1)` binary, otherwise the shell's built-in `time` command will be invoked, which doesn't have the `-l` command-line argument.
|
||||
|
||||
**Note:** The `-l` command-line argument makes `time(1)` return much more useful information, for example, maximum memory usage.
|
||||
|
||||
When running the command above, you'll see the `tart pull` output first as it pulls the image, and then the `time(1)` output, which will be printed once the Tart process finishes:
|
||||
|
||||
```
|
||||
172.17 real 10.29 user 8.36 sys
|
||||
353796096 maximum resident set size
|
||||
0 average shared memory size
|
||||
0 average unshared data size
|
||||
0 average unshared stack size
|
||||
23838 page reclaims
|
||||
35 page faults
|
||||
0 swaps
|
||||
0 block input operations
|
||||
0 block output operations
|
||||
8 messages sent
|
||||
8 messages received
|
||||
0 signals received
|
||||
146 voluntary context switches
|
||||
222950 involuntary context switches
|
||||
39683070975 instructions retired
|
||||
27562035252 cycles elapsed
|
||||
170920448 peak memory footprint
|
||||
```
|
||||
|
||||
From the output above, you can tell that `tart pull` spent nearly 90% of time off-CPU (`real` > `user` + `sys`), which means that Tart was mostly waiting for the I/O (be it a network or disk), instead of decompressing disk layers or doing other useful computations.
|
||||
|
||||
## Using `xctrace(1)`
|
||||
|
||||
[`xctrace(1)`](https://keith.github.io/xcode-man-pages/xctrace.1.html) is a `.trace` format recorder for the [Instruments](https://en.wikipedia.org/wiki/Instruments_(software)) app, which yields much more powerful insights compared to `time(1)`. For example, it can tell which Tart functions spent the most time on the CPU, thus allowing the Tart developers to further optimize these functions.
|
||||
|
||||
To use it, make sure that [Xcode](https://developer.apple.com/xcode/resources/) is installed. If you're installing Xcode for the first time on the machine, you'll need to launch it once and click the blue "Install" button. There's no need to choose any platforms except for the macOS.
|
||||
|
||||
Once done, you can create a CPU profile of `tart pull`:
|
||||
|
||||
```shell
|
||||
xctrace record --template "CPU Profiler" --target-stdout - --launch -- /opt/homebrew/bin/tart pull ghcr.io/cirruslabs/macos-sequoia-base:latest
|
||||
```
|
||||
|
||||
Now that `xctrace(1)` is running, you'll see the `tart pull`-related output first, and once finished, the following line will appear:
|
||||
|
||||
```
|
||||
Output file saved as: Launch_[...].trace
|
||||
```
|
||||
|
||||
To view this trace in the Instruments app, simply find this directory in Finder and double-click it. Instruments app will appear:
|
||||
|
||||

|
||||
|
||||
To send this trace, right-click its directory in Finder and choose "Compress [...]". This will result in a similarly named file with a `.zip` at the end, which can now be conveniently sent via email or uploaded.
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"originHash" : "6a15657d8cb1d3e2b447f31aff5b47d6a9655d2262e48ca76476ba525435269b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "antlr4",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/antlr/antlr4",
|
||||
"state" : {
|
||||
"branch" : "dev",
|
||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||
"revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
|
||||
"version" : "4.13.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,13 +19,22 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "semaphore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/groue/Semaphore",
|
||||
"state" : {
|
||||
"revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
|
||||
"version" : "8.8.0"
|
||||
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
|
||||
"version" : "8.36.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -32,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,8 +51,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser",
|
||||
"state" : {
|
||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
||||
"version" : "1.1.2"
|
||||
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||
"version" : "1.5.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -51,7 +61,7 @@
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||
"revision" : "5c8bd186f48c16af0775972700626f0b74588278"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,8 +69,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||
"version" : "1.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -68,8 +78,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||
"version" : "1.0.3"
|
||||
"revision" : "9bf03ff58ce34478e66aaee630e491823326fd06",
|
||||
"version" : "1.1.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||
"version" : "1.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -81,13 +100,31 @@
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-retry",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/fumoboy007/swift-retry",
|
||||
"state" : {
|
||||
"revision" : "df9d7b185d2e433147ec0083a73c257e665eea0d",
|
||||
"version" : "0.2.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sysctl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
|
||||
"state" : {
|
||||
"revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
|
||||
"version" : "1.6.0"
|
||||
"revision" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||
"version" : "1.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-xattr",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/jozefizso/swift-xattr",
|
||||
"state" : {
|
||||
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,8 +132,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"version" : "6.3.1"
|
||||
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||
"version" : "7.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -104,8 +141,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||
"state" : {
|
||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
||||
"version" : "0.50.6"
|
||||
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
|
||||
"version" : "0.54.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftradix",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||
"state" : {
|
||||
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||
"version" : "1.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -118,5 +164,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.7
|
||||
// swift-tools-version:5.10
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
@@ -10,17 +10,21 @@ let package = Package(
|
||||
.executable(name: "tart", targets: ["tart"])
|
||||
],
|
||||
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/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/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"),
|
||||
.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"),
|
||||
.package(url: "https://github.com/jozefizso/swift-xattr", from: "3.0.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
@@ -34,6 +38,10 @@ let package = Package(
|
||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||
.product(name: "TextTable", package: "TextTable"),
|
||||
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||
.product(name: "Semaphore", package: "Semaphore"),
|
||||
.product(name: "DMRetry", package: "swift-retry"),
|
||||
.product(name: "XAttr", package: "swift-xattr"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
|
||||
@@ -8,43 +8,32 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Easily integrates with any CI system.
|
||||
|
||||
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
|
||||
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">
|
||||
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" 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/CirrusRunnersForGHA.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Tart is also adopted by several other automation services:
|
||||
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
<a href="https://atlassian.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.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>
|
||||
</p>
|
||||
|
||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ahrefs.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||
<a href="https://www.figma.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Figma.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://mullvad.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Mullvad.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://suran.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
||||
<a href="https://krisp.ai/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.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 href="https://symflower.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||
@@ -52,15 +41,24 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
||||
<a href="https://transloadit.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://uphold.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Uphold.png" height="65"/>
|
||||
<a href="https://cirrus-ci.org/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://www.pitsdatarecovery.net/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/PITSGlobalDataRecoveryServices.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://expo.dev/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Expo.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**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 [sharing with the community](https://github.com/cirruslabs/tart/discussions/857).
|
||||
|
||||
<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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,25 @@
|
||||
<?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>CFBundleDisplayName</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: 1.1 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,4 +0,0 @@
|
||||
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
|
||||
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
|
||||
|
||||
You can refer to `Background.png` as a base for your logo.
|
||||
|
After Width: | Height: | Size: 9.0 KiB |
@@ -8,17 +8,15 @@ struct Clone: AsyncParsableCommand {
|
||||
discussion: """
|
||||
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||
|
||||
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
|
||||
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
|
||||
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
|
||||
will be modified.
|
||||
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. This also speeds up clones enormously.
|
||||
|
||||
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.
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the cloned image
|
||||
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
"""
|
||||
)
|
||||
|
||||
@Argument(help: "source VM name")
|
||||
@Argument(help: "source VM name", completion: .custom(completeMachines))
|
||||
var sourceName: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
@@ -30,6 +28,9 @@ struct Clone: AsyncParsableCommand {
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var deduplicate: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
@@ -47,7 +48,7 @@ struct Clone: AsyncParsableCommand {
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// 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)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
@@ -63,7 +64,7 @@ struct Clone: AsyncParsableCommand {
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
&& sourceVM.state() != "suspended"
|
||||
&& sourceVM.state() != .Suspended
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
@@ -73,7 +74,7 @@ struct Clone: AsyncParsableCommand {
|
||||
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
|
||||
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
|
||||
try Prune.reclaimIfNeeded(UInt64(sourceVM.allocatedSizeBytes()), sourceVM)
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Virtualization
|
||||
|
||||
struct Create: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||
@@ -15,13 +16,18 @@ struct Create: AsyncParsableCommand {
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil && !linux {
|
||||
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 {
|
||||
@@ -32,19 +38,29 @@ struct Create: AsyncParsableCommand {
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
#if arch(arm64)
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
if fromIPSW == "latest" {
|
||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||
|
||||
let image = try await withCheckedThrowingContinuation { continuation in
|
||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||
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)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
#endif
|
||||
|
||||
if linux {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
|
||||
@@ -5,7 +5,7 @@ import SwiftUI
|
||||
struct Delete: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
struct Export: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let OS: OS
|
||||
let CPU: Int
|
||||
let Memory: UInt64
|
||||
let Disk: Int
|
||||
let Size: String
|
||||
let Display: String
|
||||
let Running: Bool
|
||||
let State: String
|
||||
@@ -13,7 +15,7 @@ fileprivate struct VMInfo: Encodable {
|
||||
struct Get: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
@Option(help: "Output format: text or json")
|
||||
@@ -22,11 +24,9 @@ struct Get: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
let diskSizeInGb = try vmDir.sizeGB()
|
||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||
|
||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
||||
Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
|
||||
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)
|
||||
print(format.renderSingle(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
||||
struct IP: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
@@ -61,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
||||
return ip
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import SwiftUI
|
||||
fileprivate struct VMInfo: Encodable {
|
||||
let Source: String
|
||||
let Name: String
|
||||
let Disk: Int
|
||||
let Size: Int
|
||||
let SizeOnDisk: Int
|
||||
let Running: Bool
|
||||
let State: String
|
||||
}
|
||||
@@ -37,13 +39,13 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
if source == nil || source == "local" {
|
||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
if source == nil || source == "oci" {
|
||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
|
||||
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,10 @@ struct Login: AsyncParsableCommand {
|
||||
])
|
||||
|
||||
if !noValidate {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||
|
||||
@@ -89,7 +89,7 @@ struct Prune: AsyncParsableCommand {
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||
|
||||
if prunableSizeBytes <= spaceBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
@@ -158,7 +158,7 @@ struct Prune: AsyncParsableCommand {
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
@@ -177,9 +177,9 @@ struct Prune: AsyncParsableCommand {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ struct Pull: AsyncParsableCommand {
|
||||
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.
|
||||
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
|
||||
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||
"""
|
||||
)
|
||||
|
||||
@@ -23,6 +23,9 @@ struct Pull: AsyncParsableCommand {
|
||||
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var deduplicate: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if concurrency < 1 {
|
||||
throw ValidationError("network concurrency cannot be less than 1")
|
||||
@@ -43,6 +46,9 @@ struct Pull: AsyncParsableCommand {
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency)
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||
|
||||
// to explicitly set the image as being accessed so it won't get pruned immediately
|
||||
_ = try VMStorageOCI().open(remoteName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Compression
|
||||
struct Push: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
@@ -15,6 +15,9 @@ struct Push: AsyncParsableCommand {
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: "network concurrency to use when pushing a local VM to the OCI-compatible registry")
|
||||
var concurrency: UInt = 4
|
||||
|
||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||
discussion: """
|
||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||
@@ -33,6 +36,10 @@ struct Push: AsyncParsableCommand {
|
||||
func run() async throws {
|
||||
let ociStorage = VMStorageOCI()
|
||||
let localVMDir = try VMStorageHelper.open(localName)
|
||||
let lock = try localVMDir.lock()
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMIsRunning(localName)
|
||||
}
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
@@ -73,7 +80,8 @@ struct Push: AsyncParsableCommand {
|
||||
registry: registry,
|
||||
references: references,
|
||||
chunkSizeMb: chunkSize,
|
||||
diskFormat: diskFormat
|
||||
diskFormat: diskFormat,
|
||||
concurrency: concurrency
|
||||
)
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
|
||||
@@ -2,9 +2,9 @@ import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
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
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
@@ -20,11 +20,11 @@ struct Rename: AsyncParsableCommand {
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
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) {
|
||||
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)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct Set: AsyncParsableCommand {
|
||||
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
|
||||
|
||||
@Option(help: "Number of VM CPUs")
|
||||
@@ -16,7 +17,29 @@ struct Set: AsyncParsableCommand {
|
||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||
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("Replace the VM's disk contents with the disk contents at path.", valueName: "path"))
|
||||
var disk: String?
|
||||
|
||||
@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?
|
||||
|
||||
func run() async throws {
|
||||
@@ -40,8 +63,27 @@ 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)
|
||||
|
||||
if let disk = disk {
|
||||
let temporaryDiskURL = try Config().tartTmpDir.appendingPathComponent("set-disk-\(UUID().uuidString)")
|
||||
|
||||
try FileManager.default.copyItem(atPath: disk, toPath: temporaryDiskURL.path())
|
||||
|
||||
_ = try FileManager.default.replaceItemAt(vmDir.diskURL, withItemAt: temporaryDiskURL)
|
||||
}
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import SwiftDate
|
||||
struct Stop: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
@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 {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
switch try vmDir.state() {
|
||||
case "suspended":
|
||||
case .Suspended:
|
||||
try stopSuspended(vmDir)
|
||||
case "running":
|
||||
case .Running:
|
||||
try await stopRunning(vmDir)
|
||||
default:
|
||||
return
|
||||
case .Stopped:
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ struct Stop: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
throw RuntimeError.VMNotRunning(name)
|
||||
}
|
||||
|
||||
// Try to gracefully terminate the VM
|
||||
|
||||
@@ -6,15 +6,15 @@ import SwiftDate
|
||||
struct Suspend: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||
var name: String
|
||||
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||
let lock = try vmDir.lock()
|
||||
|
||||
// Find the VM's PID
|
||||
var pid = try lock.pid()
|
||||
let pid = try lock.pid()
|
||||
if pid == 0 {
|
||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||
}
|
||||
|
||||
@@ -39,15 +39,21 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
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!")
|
||||
}
|
||||
if outputData == nil || outputData?.count == 0 {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
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)
|
||||
|
||||
@@ -3,19 +3,32 @@ import AsyncAlgorithms
|
||||
|
||||
fileprivate let urlSession = createURLSession()
|
||||
|
||||
class Fetcher {
|
||||
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
if viaFile {
|
||||
return try await fetchViaFile(request)
|
||||
}
|
||||
|
||||
return try await fetchViaMemory(request)
|
||||
class DownloadDelegate: NSObject, URLSessionTaskDelegate {
|
||||
let progress: Progress
|
||||
init(_ progress: Progress) throws {
|
||||
self.progress = progress
|
||||
}
|
||||
|
||||
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 (data, response) = try await urlSession.data(for: request)
|
||||
let (data, response) = try await urlSession.data(for: request, delegate: delegate)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
@@ -26,10 +39,10 @@ class Fetcher {
|
||||
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 (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.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct LocalLayerCache {
|
||||
struct DigestInfo {
|
||||
let range: Range<Data.Index>
|
||||
let compressedDigest: String
|
||||
let uncompressedContentDigest: String?
|
||||
}
|
||||
|
||||
let name: String
|
||||
let deduplicatedBytes: UInt64
|
||||
let diskURL: URL
|
||||
|
||||
private let mappedDisk: Data
|
||||
private var digestToRange: [String: DigestInfo] = [:]
|
||||
private var offsetToRange: [UInt64: DigestInfo] = [:]
|
||||
|
||||
init?(_ name: String, _ deduplicatedBytes: UInt64, _ diskURL: URL, _ manifest: OCIManifest) throws {
|
||||
self.name = name
|
||||
self.deduplicatedBytes = deduplicatedBytes
|
||||
self.diskURL = diskURL
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
let info = DigestInfo(
|
||||
range: Int(offset)..<Int(offset + uncompressedSize),
|
||||
compressedDigest: layer.digest,
|
||||
uncompressedContentDigest: layer.uncompressedContentDigest()!
|
||||
)
|
||||
self.digestToRange[layer.digest] = info
|
||||
self.offsetToRange[offset] = info
|
||||
|
||||
offset += uncompressedSize
|
||||
}
|
||||
}
|
||||
|
||||
func findInfo(digest: String, offsetHint: UInt64) -> DigestInfo? {
|
||||
// Layers can have the same digests, for example, empty ones. Let's use the offset hint to make a better guess.
|
||||
if let info = self.offsetToRange[offsetHint], info.compressedDigest == digest {
|
||||
return info
|
||||
}
|
||||
return self.digestToRange[digest]
|
||||
}
|
||||
|
||||
func subdata(_ range: Range<Data.Index>) -> Data {
|
||||
return self.mappedDisk.subdata(in: range)
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ struct ARPCache {
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
@@ -60,10 +65,6 @@ struct ARPCache {
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
|
||||
self.arpCommandOutput = arpCommandOutput
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import SwiftRadix
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
var expiresAt: Date
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
guard let lease = fromRawLease["lease"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
@@ -26,7 +30,13 @@ struct Lease {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse expiration timestamp
|
||||
guard let leaseTimestamp = lease.hex?.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +37,18 @@ class Leases {
|
||||
}
|
||||
|
||||
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) }) {
|
||||
leases[lease.mac] = lease
|
||||
self.leases = Dictionary(leases) { (left, right) in
|
||||
// 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.
|
||||
@@ -107,7 +112,7 @@ class Leases {
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Virtualization
|
||||
import Semaphore
|
||||
|
||||
protocol Network {
|
||||
func attachments() -> [VZNetworkDeviceAttachment]
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func run(_ sema: AsyncSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
@@ -12,7 +13,7 @@ class NetworkBridged: Network {
|
||||
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
@@ -6,7 +7,7 @@ class NetworkShared: Network {
|
||||
[VZNATNetworkDeviceAttachment()]
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import Atomics
|
||||
import Foundation
|
||||
import Semaphore
|
||||
import System
|
||||
import Virtualization
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
@@ -15,7 +16,7 @@ class Softnet: Network {
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String) throws {
|
||||
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
@@ -30,7 +31,7 @@ class Softnet: Network {
|
||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
||||
return executableURL
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
func run(_ sema: AsyncSemaphore) throws {
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
|
||||
@@ -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,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) async throws
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer]
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ 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] {
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
|
||||
// Open the disk file
|
||||
@@ -45,7 +45,7 @@ class DiskV1: Disk {
|
||||
return pushedLayers
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
|
||||
@@ -1,43 +1,89 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
import System
|
||||
import Retry
|
||||
|
||||
class DiskV2: Disk {
|
||||
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [OCIManifestLayer] = []
|
||||
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||
var pushedLayers: [(index: Int, pushedLayer: OCIManifestLayer)] = []
|
||||
|
||||
// Open the disk file
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
|
||||
// Compress the disk file as multiple individually decompressible streams,
|
||||
// each equal ``Self.layerLimitBytes`` bytes or slightly larger due to the
|
||||
// internal compressor's buffer
|
||||
while let (compressedData, uncompressedSize, uncompressedDigest) = try compressNextLayerOfLimitBytesOrMore(disk: disk) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||
try await withThrowingTaskGroup(of: (Int, OCIManifestLayer).self) { group in
|
||||
for (index, data) in mappedDisk.chunks(ofCount: layerLimitBytes).enumerated() {
|
||||
// Respect the concurrency limit
|
||||
if index >= concurrency {
|
||||
if let (index, pushedLayer) = try await group.next() {
|
||||
pushedLayers.append((index, pushedLayer))
|
||||
}
|
||||
}
|
||||
|
||||
pushedLayers.append(OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: layerDigest,
|
||||
uncompressedSize: uncompressedSize,
|
||||
uncompressedContentDigest: uncompressedDigest
|
||||
))
|
||||
// Launch a disk layer pushing task
|
||||
group.addTask {
|
||||
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||
let compressedDataDigest = Digest.hash(compressedData)
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(uncompressedSize)
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
if try await !registry.blobExists(compressedDataDigest) {
|
||||
_ = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb, digest: compressedDataDigest)
|
||||
}
|
||||
} recoverFromFailure: { error in
|
||||
if error is URLError {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
|
||||
// Update progress using a relative value
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
|
||||
return (index, OCIManifestLayer(
|
||||
mediaType: diskV2MediaType,
|
||||
size: compressedData.count,
|
||||
digest: compressedDataDigest,
|
||||
uncompressedSize: UInt64(data.count),
|
||||
uncompressedContentDigest: Digest.hash(data)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
for try await pushedLayer in group {
|
||||
pushedLayers.append(pushedLayer)
|
||||
}
|
||||
}
|
||||
|
||||
return pushedLayers
|
||||
return pushedLayers.sorted {
|
||||
$0.index < $1.index
|
||||
}.map {
|
||||
$0.pushedLayer
|
||||
}
|
||||
}
|
||||
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress) async throws {
|
||||
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) 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
|
||||
if !pullResumed {
|
||||
if deduplicate, let localLayerCache = localLayerCache {
|
||||
// Clone the local layer cache's disk and use it as a base, potentially
|
||||
// reducing the space usage since some blocks won't be written at all
|
||||
try FileManager.default.copyItem(at: localLayerCache.diskURL, to: diskURL)
|
||||
} else {
|
||||
// Otherwise create an empty disk
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the uncompressed disk size
|
||||
@@ -57,6 +103,15 @@ class DiskV2: Disk {
|
||||
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||
try disk.close()
|
||||
|
||||
// Determine the file system block size
|
||||
var st = stat()
|
||||
if stat(diskURL.path, &st) == -1 {
|
||||
let details = Errno(rawValue: errno)
|
||||
|
||||
throw RuntimeError.PullFailed("failed to stat(2) disk \(diskURL.path): \(details)")
|
||||
}
|
||||
let fsBlockSize = UInt64(st.st_blksize)
|
||||
|
||||
// Concurrently fetch and decompress layers
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var globalDiskWritingOffset: UInt64 = 0
|
||||
@@ -81,22 +136,61 @@ class DiskV2: Disk {
|
||||
// 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 {
|
||||
if pullResumed {
|
||||
// do not check hash in the condition above to make it lazy e.g. only do expensive calculations if needed
|
||||
if try Digest.hash(diskURL, offset: diskWritingOffset, size: uncompressedLayerSize) == uncompressedLayerContentDigest {
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Open the disk file for writing
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
|
||||
// Also open the disk file for reading and verifying
|
||||
// its contents in case the local layer cache is used
|
||||
let rdisk: FileHandle? = if deduplicate && localLayerCache != nil {
|
||||
try FileHandle(forReadingFrom: diskURL)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
// Check if we already have this layer contents in the local layer cache,
|
||||
// or perhaps even on the cloned disk (when the deduplication is enabled)
|
||||
if let localLayerCache = localLayerCache,
|
||||
let localLayerInfo = localLayerCache.findInfo(digest: diskLayer.digest, offsetHint: diskWritingOffset),
|
||||
localLayerInfo.uncompressedContentDigest == uncompressedLayerContentDigest {
|
||||
if deduplicate && localLayerInfo.range.lowerBound == diskWritingOffset {
|
||||
// Do nothing, because the data is already on the disk that we've inherited from
|
||||
} else {
|
||||
// Fulfil the layer contents from the local blob cache
|
||||
let data = localLayerCache.subdata(localLayerInfo.range)
|
||||
_ = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try disk.close()
|
||||
|
||||
if let rdisk = rdisk {
|
||||
try rdisk.close()
|
||||
}
|
||||
|
||||
// Update the progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Open the disk file at the specific offset
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
try disk.seek(toOffset: diskWritingOffset)
|
||||
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
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
guard let data = data else {
|
||||
return
|
||||
}
|
||||
|
||||
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||
}
|
||||
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
@@ -109,6 +203,10 @@ class DiskV2: Disk {
|
||||
try filter.finalize()
|
||||
|
||||
try disk.close()
|
||||
|
||||
if let rdisk = rdisk {
|
||||
try rdisk.close()
|
||||
}
|
||||
}
|
||||
|
||||
globalDiskWritingOffset += uncompressedLayerSize
|
||||
@@ -116,39 +214,66 @@ class DiskV2: Disk {
|
||||
}
|
||||
}
|
||||
|
||||
private static func compressNextLayerOfLimitBytesOrMore(disk: FileHandle) throws -> (Data, UInt64, String)? {
|
||||
var compressedData = Data()
|
||||
var bytesRead: UInt64 = 0
|
||||
let digest = Digest()
|
||||
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||
let holeGranularityBytes = 64 * 1024
|
||||
|
||||
// Create a compressing filter that we will terminate upon
|
||||
// reaching ``Self.layerLimitBytes`` of compressed data
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: bufferSizeBytes) { (length: Int) -> Data? in
|
||||
if compressedData.count >= Self.layerLimitBytes {
|
||||
return nil
|
||||
// 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) {
|
||||
// If the local layer cache is used, only write chunks that differ
|
||||
// since the base disk can contain anything at any position
|
||||
if let rdisk = rdisk {
|
||||
// F_PUNCHHOLE requires the holes to be aligned to file system block boundaries
|
||||
let isHoleAligned = (offset % fsBlockSize) == 0 && (UInt64(chunk.count) % fsBlockSize) == 0
|
||||
|
||||
if isHoleAligned && chunk == zeroChunk {
|
||||
var arg = fpunchhole_t(fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(chunk.count))
|
||||
|
||||
if fcntl(disk.fileDescriptor, F_PUNCHHOLE, &arg) == -1 {
|
||||
let details = Errno(rawValue: errno)
|
||||
|
||||
throw RuntimeError.PullFailed("failed to punch hole: \(details)")
|
||||
}
|
||||
} else {
|
||||
try rdisk.seek(toOffset: offset)
|
||||
let actualContentsOnDisk = try rdisk.read(upToCount: chunk.count)
|
||||
|
||||
if chunk != actualContentsOnDisk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
offset += UInt64(chunk.count)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
guard let uncompressedChunk = try disk.read(upToCount: bufferSizeBytes) else {
|
||||
return nil
|
||||
// Otherwise, only write chunks that are not zero
|
||||
// since the base disk is created from scratch and
|
||||
// is zeroed via truncate(2)
|
||||
if chunk != zeroChunk {
|
||||
try disk.seek(toOffset: offset)
|
||||
disk.write(chunk)
|
||||
}
|
||||
|
||||
bytesRead += UInt64(uncompressedChunk.count)
|
||||
digest.update(uncompressedChunk)
|
||||
|
||||
return uncompressedChunk
|
||||
offset += UInt64(chunk.count)
|
||||
}
|
||||
|
||||
// Retrieve compressed data chunks, but normally no more than ``Self.layerLimitBytes`` bytes
|
||||
while let compressedChunk = try compressingFilter.readData(ofLength: Self.bufferSizeBytes) {
|
||||
compressedData.append(compressedChunk)
|
||||
}
|
||||
|
||||
// Nothing was read this time from the disk,
|
||||
// signal that to the consumer
|
||||
if bytesRead == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (compressedData, bytesRead, digest.finalize())
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ struct OCIManifestConfig: Codable, Equatable {
|
||||
var digest: String
|
||||
}
|
||||
|
||||
struct OCIManifestLayer: Codable, Equatable {
|
||||
struct OCIManifestLayer: Codable, Equatable, Hashable {
|
||||
var mediaType: String
|
||||
var size: Int
|
||||
var digest: String
|
||||
@@ -113,6 +113,14 @@ struct OCIManifestLayer: Codable, Equatable {
|
||||
func uncompressedContentDigest() -> String? {
|
||||
annotations?[uncompressedContentDigestAnnotation]
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
return lhs.digest == rhs.digest
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(digest)
|
||||
}
|
||||
}
|
||||
|
||||
struct Descriptor: Equatable {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
|
||||
import Antlr4
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceLexer: Lexer {
|
||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
||||
|
||||
public
|
||||
required init(_ input: CharStream) {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||
super.init(input)
|
||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
// Generated from Reference.g4 by ANTLR 4.13.2
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceParser: Parser {
|
||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "java-escape" }
|
||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
||||
|
||||
override public
|
||||
init(_ input:TokenStream) throws {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||
try super.init(input)
|
||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||
}
|
||||
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
|
||||
setState(63)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
if (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
setState(62)
|
||||
try separator()
|
||||
|
||||
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
|
||||
setState(84)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
while (((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
setState(79)
|
||||
try separator()
|
||||
setState(80)
|
||||
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(87)
|
||||
_la = try _input.LA(1)
|
||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
if (!(((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0))) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -10,6 +10,7 @@ enum RegistryError: Error {
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case HEAD = "HEAD"
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
@@ -21,6 +22,7 @@ enum HTTPCode: Int {
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case Unauthorized = 401
|
||||
case NotFound = 404
|
||||
}
|
||||
|
||||
extension Data {
|
||||
@@ -42,9 +44,6 @@ extension AsyncThrowingChannel<Data, Error> {
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String?
|
||||
var accessToken: String?
|
||||
var expiresIn: Int?
|
||||
@@ -66,7 +65,8 @@ struct TokenResponse: Decodable, Authentication {
|
||||
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 {
|
||||
throw DecodingError.keyNotFound(CodingKeys.token, .init(codingPath: [], debugDescription: "Missing token or access_token. One must be present."))
|
||||
@@ -85,7 +85,7 @@ struct TokenResponse: Decodable, Authentication {
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +102,7 @@ class Registry {
|
||||
private let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
let authenticationKeeper = AuthenticationKeeper()
|
||||
|
||||
var host: String? {
|
||||
guard let host = baseURL.host else { return nil }
|
||||
@@ -115,11 +114,11 @@ class Registry {
|
||||
return host
|
||||
}
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
init(baseURL: URL,
|
||||
namespace: String,
|
||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.baseURL = baseURL
|
||||
self.namespace = namespace
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
@@ -133,7 +132,17 @@ class Registry {
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
guard let baseURL = baseURLComponents.url else {
|
||||
var hint = ""
|
||||
|
||||
if host.hasPrefix("http://") || host.hasPrefix("https://") {
|
||||
hint += ", make sure that it doesn't start with http:// or https://"
|
||||
}
|
||||
|
||||
throw RuntimeError.ImproperlyFormattedHost(host, hint)
|
||||
}
|
||||
|
||||
try self.init(baseURL: baseURL, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
@@ -182,7 +191,7 @@ class Registry {
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0, digest: String? = nil) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
headers: ["Content-Length": "0"])
|
||||
@@ -194,7 +203,7 @@ class Registry {
|
||||
// Figure out where to upload the blob
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let digest = digest ?? Digest.hash(fromData)
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
@@ -242,6 +251,19 @@ class Registry {
|
||||
return digest
|
||||
}
|
||||
|
||||
public func blobExists(_ digest: String) async throws -> Bool {
|
||||
let (data, response) = try await dataRequest(.HEAD, endpointURL("\(namespace)/blobs/\(digest)"))
|
||||
|
||||
switch response.statusCode {
|
||||
case HTTPCode.Ok.rawValue:
|
||||
return true
|
||||
case HTTPCode.NotFound.rawValue:
|
||||
return false
|
||||
default:
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "checking blob", code: response.statusCode, details: data.asText())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
@@ -305,17 +327,12 @@ class Registry {
|
||||
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, doAuth: doAuth)
|
||||
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||
}
|
||||
|
||||
return (channel, response)
|
||||
@@ -331,7 +348,7 @@ class Registry {
|
||||
|
||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||
if let (user, password) = try lookupCredentials() {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||
}
|
||||
|
||||
return
|
||||
@@ -378,7 +395,7 @@ class Registry {
|
||||
+ "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)? {
|
||||
@@ -396,12 +413,13 @@ class Registry {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
private func authAwareRequest(request: URLRequest, viaFile: Bool = false, doAuth: Bool) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
let (name, value) = token.header()
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
if doAuth {
|
||||
if let (name, value) = await authenticationKeeper.header() {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
}
|
||||
|
||||
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||
|
||||
@@ -11,7 +11,7 @@ class PIDLock {
|
||||
if fd == -1 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
|
||||
throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,127 +6,131 @@ struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
#if arch(arm64)
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
struct Darwin: PlatformSuspendable {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
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(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()
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
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 result = VZMacGraphicsDeviceConfiguration()
|
||||
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(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 = [
|
||||
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
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
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
|
||||
)
|
||||
]
|
||||
func keyboards() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
return [VZUSBKeyboardConfiguration()]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
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 pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacKeyboardConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return keyboards()
|
||||
}
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
// Trackpad is only supported by guests starting with macOS Ventura
|
||||
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
|
||||
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 14, *) {
|
||||
return [VZMacTrackpadConfiguration()]
|
||||
} else {
|
||||
// fallback to the regular configuration
|
||||
return pointingDevices()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
||||
var url: URL { get }
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
// size on disk as seen in Finder including empty blocks
|
||||
func sizeBytes() throws -> Int
|
||||
// actual size on disk without empty blocks
|
||||
func allocatedSizeBytes() throws -> Int
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
Delete.self,
|
||||
FQN.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
@@ -49,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
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
|
||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||
SentrySDK.configureScope { scope in
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
extension URL {
|
||||
func accessDate() throws -> Date {
|
||||
@@ -13,7 +14,9 @@ extension URL {
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(details)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import XAttr
|
||||
|
||||
extension URL: Prunable {
|
||||
var url: URL {
|
||||
@@ -9,7 +10,35 @@ extension URL: Prunable {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
let values = try resourceValues(forKeys: [.totalFileAllocatedSizeKey, .mayShareFileContentKey])
|
||||
// make sure the file's origin file is there and duplication works
|
||||
if values.mayShareFileContent == true {
|
||||
return Int(deduplicatedBytes())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileSizeKey]).totalFileSize!
|
||||
}
|
||||
|
||||
func setDeduplicatedBytes(_ size: UInt64) {
|
||||
let data = "\(size)".data(using: .utf8)!
|
||||
try! self.setExtendedAttribute(name: "run.tart.deduplicated-bytes", value: data)
|
||||
}
|
||||
|
||||
func deduplicatedBytes() -> UInt64 {
|
||||
guard let data = try? self.extendedAttributeValue(forName: "run.tart.deduplicated-bytes") else {
|
||||
return 0
|
||||
}
|
||||
if let strValue = String(data: data, encoding: .utf8) {
|
||||
return UInt64(strValue) ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
import Semaphore
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
}
|
||||
@@ -30,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
var configuration: VZVirtualMachineConfiguration
|
||||
|
||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||
var sema = DispatchSemaphore(value: 0)
|
||||
var sema = AsyncSemaphore(value: 0)
|
||||
|
||||
// VM's config
|
||||
var name: String
|
||||
@@ -45,7 +46,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = [],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
@@ -61,7 +65,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||
directorySharingDevices: directorySharingDevices,
|
||||
serialPorts: serialPorts,
|
||||
suspendable: suspendable
|
||||
suspendable: suspendable,
|
||||
audio: audio,
|
||||
clipboard: clipboard,
|
||||
sync: sync
|
||||
)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
@@ -89,12 +96,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||
let downloadProgress = Progress(totalUnitCount: 100)
|
||||
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).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")
|
||||
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)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
try lock.lock()
|
||||
@@ -106,7 +118,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
@@ -116,18 +128,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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 {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
@@ -137,82 +137,90 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
#if arch(arm64)
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
network: Network = NetworkShared(),
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||
serialPorts: [VZSerialPortConfiguration] = []
|
||||
) async throws {
|
||||
var ipswURL = ipswURL
|
||||
|
||||
if !ipswURL.isFileURL {
|
||||
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)
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
}
|
||||
|
||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||
throw UnsupportedRestoreImageError()
|
||||
}
|
||||
// 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()
|
||||
|
||||
// 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 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
|
||||
// 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 {
|
||||
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, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
@@ -240,16 +248,18 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
await withTaskCancellationHandler(operation: {
|
||||
// Wait for the VM to finish running
|
||||
// or for the exit condition
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
})
|
||||
do {
|
||||
try await sema.waitUnlessCancelled()
|
||||
} catch is CancellationError {
|
||||
// Triggered by "tart stop", Ctrl+C, or closing the
|
||||
// VM window, so shut down the VM gracefully below.
|
||||
}
|
||||
|
||||
if Task.isCancelled {
|
||||
try await stop()
|
||||
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||
print("Stopping VM...")
|
||||
try await stop()
|
||||
}
|
||||
}
|
||||
|
||||
try await network.stop()
|
||||
@@ -257,9 +267,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
@MainActor
|
||||
private func start(_ recovery: Bool) async throws {
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#if arch(arm64)
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
#else
|
||||
try await virtualMachine.start()
|
||||
#endif
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -280,7 +294,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||
serialPorts: [VZSerialPortConfiguration],
|
||||
suspendable: Bool = false
|
||||
suspendable: Bool = false,
|
||||
audio: Bool = true,
|
||||
clipboard: Bool = true,
|
||||
sync: VZDiskImageSynchronizationMode = .full
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
@@ -298,16 +315,23 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
if !suspendable {
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
|
||||
if audio && !suspendable {
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
} else {
|
||||
// just a null speaker
|
||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||
}
|
||||
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||
@@ -325,10 +349,23 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
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
|
||||
var devices: [VZStorageDeviceConfiguration] = [
|
||||
VZVirtioBlockDeviceConfiguration(attachment: try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false))
|
||||
]
|
||||
let attachment: VZDiskImageStorageDeviceAttachment = vmConfig.os == .linux ?
|
||||
// Use "cached" caching mode for virtio drive to prevent fs corruption on linux
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .cached, synchronizationMode: sync) :
|
||||
try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false, cachingMode: .automatic, synchronizationMode: sync)
|
||||
|
||||
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||
devices.append(contentsOf: additionalStorageDevices)
|
||||
configuration.storageDevices = devices
|
||||
|
||||
|
||||
@@ -95,7 +95,14 @@ struct VMConfig: Codable {
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
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:
|
||||
platform = try Linux(from: decoder)
|
||||
}
|
||||
@@ -132,20 +139,30 @@ struct VMConfig: Codable {
|
||||
}
|
||||
|
||||
mutating func setCPU(cpuCount: Int) throws {
|
||||
if cpuCount < cpuCountMin {
|
||||
if os == .darwin && cpuCount < cpuCountMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||
+ " at minimum (requested \(cpuCount))")
|
||||
}
|
||||
|
||||
self.cpuCount = cpuCount
|
||||
}
|
||||
|
||||
mutating func setMemory(memorySize: UInt64) throws {
|
||||
if memorySize < memorySizeMin {
|
||||
if os == .darwin && memorySize < memorySizeMin {
|
||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||
+ " 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Compression
|
||||
import Foundation
|
||||
import Sentry
|
||||
|
||||
@@ -10,18 +11,7 @@ enum OCIError: Error {
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
func pullFromRegistry(registry: Registry, reference: String, concurrency: UInt) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
||||
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
}
|
||||
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt) async throws {
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == configMediaType
|
||||
@@ -61,7 +51,19 @@ extension VMDirectory {
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL, concurrency: concurrency, progress: progress)
|
||||
do {
|
||||
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||
concurrency: concurrency, progress: progress,
|
||||
localLayerCache: localLayerCache,
|
||||
deduplicate: deduplicate)
|
||||
} catch let error where error is FilterError {
|
||||
throw RuntimeError.PullFailed("failed to decompress disk: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
if deduplicate, let llc = localLayerCache {
|
||||
// set custom attribute to remember deduplicated bytes
|
||||
diskURL.setDeduplicatedBytes(llc.deduplicatedBytes)
|
||||
}
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
@@ -80,9 +82,12 @@ extension VMDirectory {
|
||||
nvram.write(data)
|
||||
}
|
||||
try nvram.close()
|
||||
|
||||
// Serialize VM's manifest to enable better deduplication on subsequent "tart pull"'s
|
||||
try manifest.toJSON().write(to: manifestURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
@@ -101,9 +106,9 @@ extension VMDirectory {
|
||||
|
||||
switch diskFormat {
|
||||
case "v1":
|
||||
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
case "v2":
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, progress: progress))
|
||||
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||
default:
|
||||
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import Virtualization
|
||||
import CryptoKit
|
||||
|
||||
struct VMDirectory: Prunable {
|
||||
enum State: String {
|
||||
case Running = "running"
|
||||
case Suspended = "suspended"
|
||||
case Stopped = "stopped"
|
||||
}
|
||||
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -17,6 +23,9 @@ struct VMDirectory: Prunable {
|
||||
var stateURL: URL {
|
||||
baseURL.appendingPathComponent("state.vzvmsave")
|
||||
}
|
||||
var manifestURL: URL {
|
||||
baseURL.appendingPathComponent("manifest.json")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
@@ -30,26 +39,30 @@ struct VMDirectory: Prunable {
|
||||
baseURL
|
||||
}
|
||||
|
||||
func lock() throws -> PIDLock {
|
||||
try PIDLock(lockURL: configURL)
|
||||
}
|
||||
|
||||
func running() throws -> Bool {
|
||||
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||
// which is fine to report as "not running".
|
||||
//
|
||||
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||
guard let lock = try? PIDLock(lockURL: configURL) else {
|
||||
guard let lock = try? lock() else {
|
||||
return false
|
||||
}
|
||||
|
||||
return try lock.pid() != 0
|
||||
}
|
||||
|
||||
func state() throws -> String {
|
||||
func state() throws -> State {
|
||||
if try running() {
|
||||
return "running"
|
||||
return State.Running
|
||||
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||
return "suspended"
|
||||
return State.Suspended
|
||||
} else {
|
||||
return "stopped"
|
||||
return State.Stopped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,20 +143,53 @@ struct VMDirectory: Prunable {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
|
||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||
// macOS considers kilo being 1000 and not 1024
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||
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()
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
let lock = try lock()
|
||||
|
||||
if try !lock.trylock() {
|
||||
throw RuntimeError.VMIsRunning(name)
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func allocatedSizeBytes() throws -> Int {
|
||||
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||
}
|
||||
|
||||
func allocatedSizeGB() throws -> Int {
|
||||
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func deduplicatedSizeBytes() throws -> Int {
|
||||
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||
}
|
||||
|
||||
func deduplicatedSizeGB() throws -> Int {
|
||||
try deduplicatedSizeBytes() / 1000 / 1000 / 1000
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ class VMStorageHelper {
|
||||
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
||||
do {
|
||||
return try closure()
|
||||
} catch RuntimeError.PIDLockMissing {
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError.VMDoesNotExist(name: name)
|
||||
@@ -50,14 +52,19 @@ enum RuntimeError : Error {
|
||||
case VMConfigurationError(_ message: String)
|
||||
case VMDoesNotExist(name: String)
|
||||
case VMMissingFiles(_ message: String)
|
||||
case VMNotRunning(_ message: String)
|
||||
case VMIsRunning(_ name: String)
|
||||
case VMNotRunning(_ name: String)
|
||||
case VMAlreadyRunning(_ message: String)
|
||||
case NoIPAddressFound(_ message: String)
|
||||
case DiskAlreadyInUse(_ message: String)
|
||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||
case InvalidDiskSize(_ message: String)
|
||||
case FailedToUpdateAccessDate(_ message: String)
|
||||
case PIDLockFailed(_ message: String)
|
||||
case PIDLockMissing(_ message: String)
|
||||
case FailedToParseRemoteName(_ message: String)
|
||||
case VMTerminationFailed(_ message: String)
|
||||
case ImproperlyFormattedHost(_ host: String, _ hint: String)
|
||||
case InvalidCredentials(_ message: String)
|
||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||
case ExportFailed(_ message: String)
|
||||
@@ -66,6 +73,8 @@ enum RuntimeError : Error {
|
||||
case OCIStorageError(_ message: String)
|
||||
case OCIUnsupportedDiskFormat(_ format: String)
|
||||
case SuspendFailed(_ message: String)
|
||||
case PullFailed(_ message: String)
|
||||
case VirtualMachineLimitExceeded(_ hint: String)
|
||||
}
|
||||
|
||||
protocol HasExitCode {
|
||||
@@ -81,22 +90,32 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "the specified VM \"\(name)\" does not exist"
|
||||
case .VMMissingFiles(let message):
|
||||
return message
|
||||
case .VMNotRunning(let message):
|
||||
return message
|
||||
case .VMIsRunning(let name):
|
||||
return "VM \"\(name)\" is running"
|
||||
case .VMNotRunning(let name):
|
||||
return "VM \"\(name)\" is not running"
|
||||
case .VMAlreadyRunning(let message):
|
||||
return message
|
||||
case .NoIPAddressFound(let message):
|
||||
return message
|
||||
case .DiskAlreadyInUse(let 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):
|
||||
return message
|
||||
case .PIDLockFailed(let message):
|
||||
return message
|
||||
case .PIDLockMissing(let message):
|
||||
return message
|
||||
case .FailedToParseRemoteName(let cause):
|
||||
return "failed to parse remote name: \(cause)"
|
||||
case .VMTerminationFailed(let message):
|
||||
return message
|
||||
case .ImproperlyFormattedHost(let host, let hint):
|
||||
return "improperly formatted host \"\(host)\" was provided\(hint)"
|
||||
case .InvalidCredentials(let message):
|
||||
return message
|
||||
case .VMDirectoryAlreadyInitialized(let message):
|
||||
@@ -113,6 +132,10 @@ extension RuntimeError : CustomStringConvertible {
|
||||
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||
case .SuspendFailed(let message):
|
||||
return "Failed to suspend the VM: \(message)"
|
||||
case .PullFailed(let message):
|
||||
return message
|
||||
case .VirtualMachineLimitExceeded(let hint):
|
||||
return "The number of VMs exceeds the system limit\(hint)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class VMStorageLocal: PrunableStorage {
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
@@ -66,7 +66,7 @@ class VMStorageLocal: PrunableStorage {
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().map { (_, vmDir) in vmDir }
|
||||
try list().map { (_, vmDir) in vmDir }.filter { try !$0.running() }
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Sentry
|
||||
import Retry
|
||||
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
@@ -112,6 +113,10 @@ class VMStorageOCI: PrunableStorage {
|
||||
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]
|
||||
var name: String
|
||||
|
||||
@@ -122,6 +127,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
// Remove the percent-encoding, if any
|
||||
name = percentDecode(name)
|
||||
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
@@ -132,9 +140,9 @@ class VMStorageOCI: PrunableStorage {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt) async throws {
|
||||
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||
SentrySDK.configureScope { scope in
|
||||
scope.setContext(value: ["imageName": name], key: "OCI")
|
||||
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
@@ -188,7 +196,31 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency)
|
||||
try await retry(maxAttempts: 5, backoff: .exponentialWithFullJitter(baseDelay: .seconds(5), maxDelay: .seconds(60))) {
|
||||
// Choose the best base image which has the most deduplication ratio
|
||||
let localLayerCache = try await chooseLocalLayerCache(name, manifest, registry)
|
||||
|
||||
if let llc = localLayerCache {
|
||||
let deduplicatedHuman = ByteCountFormatter.string(fromByteCount: Int64(llc.deduplicatedBytes), countStyle: .file)
|
||||
|
||||
if deduplicate {
|
||||
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to deduplicate \(deduplicatedHuman), using it as a base...")
|
||||
} else {
|
||||
defaultLogger.appendNewLine("found an image \(llc.name) that will allow us to avoid fetching \(deduplicatedHuman), will try use it...")
|
||||
}
|
||||
}
|
||||
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest, concurrency: concurrency, localLayerCache: localLayerCache, deduplicate: deduplicate)
|
||||
} recoverFromFailure: { error in
|
||||
if error is Retryable {
|
||||
print("Error: \(error.localizedDescription)")
|
||||
print("Attempting to re-try...")
|
||||
|
||||
return .retry
|
||||
}
|
||||
|
||||
return .throw
|
||||
}
|
||||
try move(digestName, from: tmpVMDir)
|
||||
transaction.finish()
|
||||
}, onCancel: {
|
||||
@@ -219,21 +251,72 @@ class VMStorageOCI: PrunableStorage {
|
||||
}
|
||||
|
||||
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 gc()
|
||||
}
|
||||
|
||||
func chooseLocalLayerCache(_ name: RemoteName, _ manifest: OCIManifest, _ registry: Registry) async throws -> LocalLayerCache? {
|
||||
// Establish a closure that will calculate how much bytes
|
||||
// we'll deduplicate if we re-use the given manifest
|
||||
let target = Swift.Set(manifest.layers)
|
||||
|
||||
let calculateDeduplicatedBytes = { (manifest: OCIManifest) -> UInt64 in
|
||||
target.intersection(manifest.layers).map({ UInt64($0.size) }).reduce(0, +)
|
||||
}
|
||||
|
||||
// Load OCI VM images and their manifests (if present)
|
||||
var candidates: [(name: String, vmDir: VMDirectory, manifest: OCIManifest, deduplicatedBytes: UInt64)] = []
|
||||
|
||||
for (name, vmDir, isSymlink) in try list() {
|
||||
if isSymlink {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let manifestJSON = try? Data(contentsOf: vmDir.manifestURL) else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let manifest = try? OCIManifest(fromJSON: manifestJSON) else {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates.append((name, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Previously we haven't stored the OCI VM image manifests, but still fetched the VM image manifest if
|
||||
// what the user was trying to pull was a tagged image, and we already had that image in the OCI VM cache
|
||||
//
|
||||
// Keep supporting this behavior for backwards comaptibility, but only communicate
|
||||
// with the registry if we haven't already retrieved the manifest for that OCI VM image.
|
||||
if name.reference.type == .Tag,
|
||||
let vmDir = try? open(name),
|
||||
let digest = try? digest(name),
|
||||
try !candidates.contains(where: {try $0.manifest.digest() == digest}),
|
||||
let (manifest, _) = try? await registry.pullManifest(reference: digest) {
|
||||
candidates.append((name.description, vmDir, manifest, calculateDeduplicatedBytes(manifest)))
|
||||
}
|
||||
|
||||
// Now, find the best match based on how many bytes we'll deduplicate
|
||||
let choosen = candidates.filter {
|
||||
$0.deduplicatedBytes > 1024 * 1024 * 1024 // save at least 1GB
|
||||
}.max { left, right in
|
||||
return left.deduplicatedBytes < right.deduplicatedBytes
|
||||
}
|
||||
|
||||
return try choosen.flatMap({ choosen in
|
||||
try LocalLayerCache(choosen.name, choosen.deduplicatedBytes, choosen.vmDir.diskURL, choosen.manifest)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -241,6 +324,23 @@ extension 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()
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
|
||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
||||
self.vmConfig = vmConfig
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||
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 {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func waitForURL(netBridged: Bool) async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
import Virtualization
|
||||
|
||||
final class DirectoryShareTests: XCTestCase {
|
||||
func testNamedParsing() throws {
|
||||
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"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testNamedReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||
XCTAssertEqual(share.name, "build")
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertFalse(share.readOnly)
|
||||
}
|
||||
|
||||
|
||||
func testOptionalNameReadOnlyParsing() throws {
|
||||
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||
XCTAssertNil(share.name)
|
||||
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||
XCTAssertTrue(share.readOnly)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ final class LayerizerTests: XCTestCase {
|
||||
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())
|
||||
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
@@ -57,7 +57,7 @@ final class LayerizerTests: XCTestCase {
|
||||
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())
|
||||
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||
|
||||
print("pulling disk...")
|
||||
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||
|
||||
@@ -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
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
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 {
|
||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
lease=0x7fffffff
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
lease=0x7fffffff
|
||||
}
|
||||
""")
|
||||
|
||||
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"),
|
||||
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 {
|
||||
func testBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.token, "some token")
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
var expectedTokenExpiresAtRange = DateInterval()
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
|
||||
@@ -39,7 +39,7 @@ class RegistryRunner {
|
||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
|
||||
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||
registry = try Registry(baseURL: URL(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||
namespace: "vm-image")
|
||||
|
||||
// Wait for the Docker Registry to start
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 210 KiB |