Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e27da23f4c | ||
|
|
e6a30b07e3 | ||
|
|
2d7615bdf8 | ||
|
|
31ab4218f7 | ||
|
|
32ebc5bdbc | ||
|
|
c825ba4cb1 | ||
|
|
2db3918930 | ||
|
|
4256330f39 | ||
|
|
0794edf15a | ||
|
|
8536c16bcc | ||
|
|
589d489782 | ||
|
|
b1e88e1e51 | ||
|
|
b4de3bee83 | ||
|
|
cd0f238a67 | ||
|
|
02f94720c5 | ||
|
|
c0443060cf | ||
|
|
9c879b3f55 | ||
|
|
f7b38769a9 | ||
|
|
7c1ed4640f | ||
|
|
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 | ||
|
|
d8b69de52d | ||
|
|
c4c2bfeded | ||
|
|
b95585b56b | ||
|
|
8d5574ed3f | ||
|
|
457c2bc7db | ||
|
|
4bf9bdd531 | ||
|
|
8e9d61d5f5 | ||
|
|
71d03226fe | ||
|
|
36dab9878d | ||
|
|
f634002813 | ||
|
|
8e79669afb | ||
|
|
2da8bc0fb5 | ||
|
|
2d984ba194 | ||
|
|
50ce44c3eb | ||
|
|
c9e49ceb39 | ||
|
|
6df50e55d8 | ||
|
|
1fd710d00d | ||
|
|
4f6c7e79e1 | ||
|
|
1afb43e85b | ||
|
|
954cac3bee | ||
|
|
3ff4fc34c6 | ||
|
|
e118b42b1f | ||
|
|
f823190039 | ||
|
|
27cadc3f3b | ||
|
|
d4d3852745 | ||
|
|
4bb248e7b4 | ||
|
|
f45551cbf0 | ||
|
|
f68297097e | ||
|
|
637a2387e7 | ||
|
|
050d6a6ff1 | ||
|
|
5eddd1ce41 | ||
|
|
35f5b30bc4 | ||
|
|
8b27fea745 | ||
|
|
e00f62c95a | ||
|
|
d90893e9a0 | ||
|
|
feb733a7c0 | ||
|
|
545b6fcd94 | ||
|
|
c750d63ac9 | ||
|
|
704811e671 | ||
|
|
d4fcecd47c | ||
|
|
33ca96e1a0 | ||
|
|
4ce06279ff | ||
|
|
fa97adfc9e | ||
|
|
6c377029d6 | ||
|
|
93a1b70ecb | ||
|
|
cf9a3a9221 | ||
|
|
ad566faa23 | ||
|
|
1afaa7ec7a | ||
|
|
826e508646 | ||
|
|
8653ca4115 | ||
|
|
63b74f407b | ||
|
|
3a2cba6929 | ||
|
|
7592b86663 | ||
|
|
e8dbb86fc0 | ||
|
|
d2ed4ef801 | ||
|
|
2014de7dac | ||
|
|
1f23b24920 | ||
|
|
6ce4a06089 | ||
|
|
1a2f187ac8 | ||
|
|
285bf9b6c2 | ||
|
|
415ed3388d | ||
|
|
870b414994 | ||
|
|
be7011bf11 | ||
|
|
859050cb42 | ||
|
|
4f321ec264 | ||
|
|
1b53ce42f8 | ||
|
|
e89ef32a83 | ||
|
|
62a34bf89f | ||
|
|
0608b2b9d1 | ||
|
|
91e859de9b | ||
|
|
c79da6a12b | ||
|
|
2b7ca12324 | ||
|
|
9016fcfdd4 | ||
|
|
546238d9df | ||
|
|
2b6818c493 | ||
|
|
cf49fd10b6 | ||
|
|
59b3e0c0fb | ||
|
|
7bbdfc06e7 | ||
|
|
637c54e1d1 | ||
|
|
e611d97b69 | ||
|
|
8e11bbe1cd | ||
|
|
6de31de6bf | ||
|
|
37ae7888e6 | ||
|
|
64482f4345 | ||
|
|
4f70d01dd6 | ||
|
|
9098eaf024 | ||
|
|
337d95ac95 | ||
|
|
3eb8ae2aa5 | ||
|
|
b03408f856 | ||
|
|
c749bdeaf1 | ||
|
|
8e75a59d54 | ||
|
|
1d3aa5ac81 | ||
|
|
261c1806df | ||
|
|
92a4b3164d | ||
|
|
3fdf82079a | ||
|
|
a05684157e | ||
|
|
e62e921eec | ||
|
|
e1bb565c3b | ||
|
|
9b26d30c42 | ||
|
|
ab32cb7e60 |
@@ -5,8 +5,10 @@ set -e
|
|||||||
export VERSION="${CIRRUS_TAG:-0}"
|
export VERSION="${CIRRUS_TAG:-0}"
|
||||||
|
|
||||||
mkdir -p .ci/pkg/
|
mkdir -p .ci/pkg/
|
||||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/tart
|
cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
|
||||||
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
|
||||||
|
cp Resources/AppIcon.png .ci/pkg/AppIcon.png
|
||||||
|
cp Resources/Info.plist .ci/pkg/Info.plist
|
||||||
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
|
||||||
--scripts .ci/pkg/scripts \
|
--scripts .ci/pkg/scripts \
|
||||||
--install-location "/Library/Application Support/Tart" \
|
--install-location "/Library/Application Support/Tart" \
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# fix structure
|
# fix structure
|
||||||
mkdir -p "$2/tart.app/Contents/MacOS"
|
mkdir -p "$2/tart.app/Contents/MacOS" "$2/tart.app/Resources"
|
||||||
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
|
||||||
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
|
||||||
|
mv "$2/AppIcon.png" "$2/tart.app/Resources/AppIcon.png"
|
||||||
|
mv "$2/Info.plist" "$2/tart.app/Contents/Info.plist"
|
||||||
|
|
||||||
echo "#!/bin/sh" > /usr/local/bin/tart
|
echo "#!/bin/sh" > /usr/local/bin/tart
|
||||||
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
|
||||||
|
|||||||
@@ -3,3 +3,5 @@
|
|||||||
TMPFILE=$(mktemp)
|
TMPFILE=$(mktemp)
|
||||||
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
envsubst < Sources/tart/CI/CI.swift > $TMPFILE
|
||||||
mv $TMPFILE Sources/tart/CI/CI.swift
|
mv $TMPFILE Sources/tart/CI/CI.swift
|
||||||
|
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string ${CIRRUS_TAG}" Resources/Info.plist
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
use_compute_credits: true
|
use_compute_credits: true
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Sonoma
|
||||||
alias: test
|
alias: test
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: dev-mini
|
name: dev-mini
|
||||||
|
resources:
|
||||||
|
tart-vms: 1
|
||||||
|
build_script:
|
||||||
|
- swift build
|
||||||
test_script:
|
test_script:
|
||||||
- swift test
|
- swift test
|
||||||
integration_test_script:
|
integration_test_script:
|
||||||
# Build Tart
|
|
||||||
- swift build
|
|
||||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||||
# Run integration tests
|
# Run integration tests
|
||||||
@@ -24,11 +26,19 @@ task:
|
|||||||
path: "integration-tests/pytest-junit.xml"
|
path: "integration-tests/pytest-junit.xml"
|
||||||
format: junit
|
format: junit
|
||||||
|
|
||||||
|
task:
|
||||||
|
name: Markdown Lint
|
||||||
|
only_if: $CIRRUS_BRANCH != 'gh-pages' && changesInclude('**.md')
|
||||||
|
container:
|
||||||
|
image: node:latest
|
||||||
|
install_script: npm install -g markdownlint-cli
|
||||||
|
lint_script: markdownlint --config=docs/.markdownlint.yml docs/
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Lint
|
name: Lint
|
||||||
alias: lint
|
alias: lint
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
lint_script:
|
lint_script:
|
||||||
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
|
||||||
always:
|
always:
|
||||||
@@ -37,24 +47,28 @@ task:
|
|||||||
format: swiftformat
|
format: swiftformat
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Build
|
|
||||||
alias: build
|
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
|
env:
|
||||||
|
matrix:
|
||||||
|
BUILD_ARCH: arm64
|
||||||
|
BUILD_ARCH: x86_64
|
||||||
|
name: Build ($BUILD_ARCH)
|
||||||
|
alias: build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
build_script: swift build --product tart
|
build_script: swift build --arch $BUILD_ARCH --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
path: .build/debug/tart
|
path: .build/$BUILD_ARCH-apple-macosx/debug/tart
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Release (Dry Run)
|
|
||||||
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
only_if: $CIRRUS_TAG == '' && ($CIRRUS_USER_PERMISSION == 'write' || $CIRRUS_USER_PERMISSION == 'admin')
|
||||||
|
name: Release (Dry Run)
|
||||||
depends_on:
|
depends_on:
|
||||||
- lint
|
- lint
|
||||||
- build
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -70,13 +84,13 @@ task:
|
|||||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||||
install_script:
|
install_script:
|
||||||
- brew install go goreleaser/tap/goreleaser-pro getsentry/tools/sentry-cli
|
- brew install go goreleaser/tap/goreleaser-pro
|
||||||
- brew install mitchellh/gon/gon
|
- brew install mitchellh/gon/gon
|
||||||
info_script:
|
info_script:
|
||||||
- security find-identity -v
|
- security find-identity -v
|
||||||
- xcodebuild -version
|
- xcodebuild -version
|
||||||
- swift -version
|
- swift -version
|
||||||
goreleaser_script: goreleaser release --skip-publish --snapshot --clean
|
goreleaser_script: goreleaser release --skip=publish --snapshot --clean
|
||||||
always:
|
always:
|
||||||
dist_artifacts:
|
dist_artifacts:
|
||||||
path: "dist/*"
|
path: "dist/*"
|
||||||
@@ -89,7 +103,7 @@ task:
|
|||||||
- test
|
- test
|
||||||
- build
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-runner:sonoma
|
||||||
env:
|
env:
|
||||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||||
@@ -97,7 +111,7 @@ task:
|
|||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
SENTRY_ORG: cirrus-labs
|
SENTRY_ORG: cirrus-labs
|
||||||
SENTRY_PROJECT: persistent-workers
|
SENTRY_PROJECT: persistent-workers
|
||||||
SENTRY_AUTH_TOKEN: ENCRYPTED[!c16a5cf7da5f856b4bc2f21fe8cb7aa2a6c981f851c094ed4d3025fd02ea59a58a86cee8b193a69a1fc20fa217e56ac3!]
|
SENTRY_AUTH_TOKEN: ENCRYPTED[!9eaf2875d51b113e2f68598441ff8e6b2e53242e48fcb93633bd75a373fbe2e7caa900d837cc92f0b142b65579731644!]
|
||||||
setup_script:
|
setup_script:
|
||||||
- cd $HOME
|
- cd $HOME
|
||||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||||
@@ -116,13 +130,15 @@ task:
|
|||||||
- swift -version
|
- swift -version
|
||||||
release_script: goreleaser
|
release_script: goreleaser
|
||||||
upload_sentry_debug_files_script:
|
upload_sentry_debug_files_script:
|
||||||
- cd .build/arm64-apple-macosx/debug/
|
- cd .build/arm64-apple-macosx/release/
|
||||||
# Generate and upload symbols
|
# Generate and upload symbols
|
||||||
- dsymutil tart
|
- dsymutil tart
|
||||||
- sentry-cli debug-files upload tart.dSYM/
|
- sentry-cli debug-files upload tart.dSYM/
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.dSYM/
|
||||||
# Bundle and upload sources
|
# Bundle and upload sources
|
||||||
- sentry-cli debug-files bundle-sources tart.dSYM
|
- sentry-cli debug-files bundle-sources tart.dSYM
|
||||||
- sentry-cli debug-files upload tart.src.zip
|
- sentry-cli debug-files upload tart.src.zip
|
||||||
|
- SENTRY_PROJECT=tart sentry-cli debug-files upload tart.src.zip
|
||||||
create_sentry_release_script:
|
create_sentry_release_script:
|
||||||
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
- export SENTRY_RELEASE="tart@$CIRRUS_TAG"
|
||||||
- sentry-cli releases new $SENTRY_RELEASE
|
- sentry-cli releases new $SENTRY_RELEASE
|
||||||
|
|||||||
@@ -16,3 +16,6 @@ dist/
|
|||||||
|
|
||||||
# mkdocs
|
# mkdocs
|
||||||
.cache
|
.cache
|
||||||
|
|
||||||
|
# mkdocs-material
|
||||||
|
site
|
||||||
|
|||||||
@@ -3,53 +3,55 @@ project_name: tart
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- .ci/set-version.sh
|
- .ci/set-version.sh
|
||||||
- swift build -c debug --product tart
|
- swift build --arch x86_64 -c release --product tart
|
||||||
|
- swift build --arch arm64 -c release --product tart
|
||||||
|
- gon gon.hcl
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- builder: prebuilt
|
- id: tart
|
||||||
|
builder: prebuilt
|
||||||
|
goamd64: [v1]
|
||||||
goos:
|
goos:
|
||||||
- darwin
|
- darwin
|
||||||
goarch:
|
goarch:
|
||||||
- arm64
|
- arm64
|
||||||
|
- amd64
|
||||||
|
binary: tart.app/Contents/MacOS/tart
|
||||||
prebuilt:
|
prebuilt:
|
||||||
path: .build/arm64-apple-macosx/debug/tart
|
path: '.build/{{- if eq .Arch "arm64" }}arm64{{- else }}x86_64{{ end }}-apple-macosx/release/tart'
|
||||||
hooks:
|
|
||||||
post:
|
|
||||||
- gon gon.hcl
|
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- name_template: "{{ .ProjectName }}"
|
- name_template: "{{ .ProjectName }}-{{ .Arch }}"
|
||||||
files:
|
files:
|
||||||
- src: Resources/embedded.provisionprofile
|
- src: Resources/embedded.provisionprofile
|
||||||
dst: tart.app/Contents
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
- src: ".build/arm64-apple-macosx/debug/tart"
|
- src: Resources/Info.plist
|
||||||
dst: tart.app/Contents/MacOS
|
dst: tart.app/Contents
|
||||||
strip_parent: true
|
strip_parent: true
|
||||||
|
- src: Resources/AppIcon.png
|
||||||
|
dst: tart.app/Contents/Resources
|
||||||
|
strip_parent: true
|
||||||
|
- LICENSE
|
||||||
|
|
||||||
release:
|
release:
|
||||||
prerelease: auto
|
prerelease: auto
|
||||||
|
|
||||||
brews:
|
brews:
|
||||||
- name: tart
|
- name: tart
|
||||||
tap:
|
repository:
|
||||||
owner: cirruslabs
|
owner: cirruslabs
|
||||||
name: homebrew-cli
|
name: homebrew-cli
|
||||||
caveats: See the GitHub repository for more information
|
caveats: See the GitHub repository for more information
|
||||||
homepage: https://github.com/cirruslabs/tart
|
homepage: https://github.com/cirruslabs/tart
|
||||||
license: "Fair Source"
|
license: "Fair Source"
|
||||||
description: Run macOS VMs on Apple Silicon
|
description: Run macOS and Linux VMs on Apple Hardware
|
||||||
skip_upload: auto
|
skip_upload: auto
|
||||||
dependencies:
|
dependencies:
|
||||||
- "cirruslabs/cli/softnet"
|
- "cirruslabs/cli/softnet"
|
||||||
install: |
|
install: |
|
||||||
libexec.install Dir["*"]
|
libexec.install Dir["*"]
|
||||||
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
|
||||||
|
generate_completions_from_executable(libexec/"tart.app/Contents/MacOS/tart", "--generate-completion-script")
|
||||||
custom_block: |
|
custom_block: |
|
||||||
depends_on :macos => :monterey
|
depends_on :macos => :ventura
|
||||||
|
|
||||||
on_macos do
|
|
||||||
unless Hardware::CPU.arm?
|
|
||||||
odie "Tart only works on Apple Silicon!"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="sign debug" type="ShConfigurationType">
|
|
||||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart" />
|
|
||||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
|
||||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/sign.sh" />
|
|
||||||
<option name="SCRIPT_OPTIONS" value="" />
|
|
||||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
|
||||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
||||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
|
||||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
|
||||||
<option name="INTERPRETER_OPTIONS" value="" />
|
|
||||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
|
||||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
|
||||||
<envs />
|
|
||||||
<method v="2" />
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="tart create" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="create latest --from-ipsw=latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
|
||||||
<method v="2">
|
|
||||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
|
||||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
|
||||||
<configuration default="false" name="tart run" type="SwiftPackageManagerRunConfiguration" factoryName="Swift Package Run" PROGRAM_PARAMS="run latest" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="tart" TARGET_NAME="tart" CONFIG_NAME="tart" RUN_TARGET_PROJECT_NAME="tart" RUN_TARGET_NAME="tart" WAS_MODIFIED="">
|
|
||||||
<method v="2">
|
|
||||||
<option name="SPM.BUILD_TASK_PROVIDER" enabled="true" />
|
|
||||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="sign debug" run_configuration_type="ShConfigurationType" />
|
|
||||||
</method>
|
|
||||||
</configuration>
|
|
||||||
</component>
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Contributing to Tart
|
||||||
|
|
||||||
|
Table of Contents
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
- [How to Build](#how-to-build)
|
||||||
|
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
|
||||||
|
- [Style Guidelines](#style-guidelines)
|
||||||
|
- [Pull Requests](#Pull-Requests)
|
||||||
|
|
||||||
|
## How to Build
|
||||||
|
|
||||||
|
1. Fork the repository to your own GitHub account
|
||||||
|
2. Clone the forked repository to your local machine
|
||||||
|
3. If using Xcode, use from Xcode 15 or newer
|
||||||
|
4. Run ./scripts/run-signed.sh from the root of your repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-signed.sh list
|
||||||
|
```
|
||||||
|
## How to Create an Issue/Enhancement
|
||||||
|
|
||||||
|
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
|
||||||
|
2. Click on the "New Issue" button
|
||||||
|
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
|
||||||
|
4. Submit the issue
|
||||||
|
|
||||||
|
## Style Guidelines
|
||||||
|
|
||||||
|
1. Code should follow camel case
|
||||||
|
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
|
||||||
|
```bash
|
||||||
|
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
1. Provide a detailed description of the changes you made in the pull request
|
||||||
|
2. Wait for pull request to be reviewed
|
||||||
|
3. Make adjustments if necessary
|
||||||
@@ -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" : "22b3726bc4e4c6e9c04ac97cb08a82967feb39960a93d2909768a16e11576748",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "antlr4",
|
"identity" : "antlr4",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/antlr/antlr4",
|
"location" : "https://github.com/antlr/antlr4",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "dev",
|
"revision" : "cc82115a4e7f53d71d9d905caa2c2dfa4da58899",
|
||||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
"version" : "4.13.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -18,13 +19,22 @@
|
|||||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "semaphore",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/groue/Semaphore",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "2543679282aa6f6c8ecf2138acd613ed20790bc2",
|
||||||
|
"version" : "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sentry-cocoa",
|
"identity" : "sentry-cocoa",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "ac224c437a3070ffe34460137ac8761eddaf2852",
|
"revision" : "5575af93efb776414f243e93d6af9f6258dc539a",
|
||||||
"version" : "8.3.3"
|
"version" : "8.36.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -32,8 +42,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-algorithms",
|
"location" : "https://github.com/apple/swift-algorithms",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
"revision" : "f6919dfc309e7f1b56224378b11e28bab5bccc42",
|
||||||
"version" : "1.0.0"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -41,17 +51,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-argument-parser",
|
"location" : "https://github.com/apple/swift-argument-parser",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
"revision" : "41982a3656a71c768319979febd796c6fd111d5c",
|
||||||
"version" : "1.1.2"
|
"version" : "1.5.0"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "swift-async-algorithms",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,17 +60,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-atomics.git",
|
"location" : "https://github.com/apple/swift-atomics.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
|
||||||
"version" : "1.0.2"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-collections",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-collections.git",
|
"location" : "https://github.com/apple/swift-log.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
"revision" : "9cb486020ebf03bfa5b5df985387a14a98744537",
|
||||||
"version" : "1.0.3"
|
"version" : "1.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,13 +82,40 @@
|
|||||||
"version" : "1.0.2"
|
"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" : "a91be36de6803ebe48f678699dfd0694c2200d2f",
|
||||||
|
"version" : "1.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-xattr",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/jozefizso/swift-xattr",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "f8605af7b3290dbb235fb182ec6e9035d0c8c3ac",
|
||||||
|
"version" : "3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftdate",
|
"identity" : "swiftdate",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/malcommac/SwiftDate",
|
"location" : "https://github.com/malcommac/SwiftDate",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "5d943224c3bb173e6ecf27295611615eba90c80e",
|
||||||
"version" : "6.3.1"
|
"version" : "7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,8 +123,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
"location" : "https://github.com/nicklockwood/SwiftFormat",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
|
"revision" : "ab6844edb79a7b88dc6320e6cee0a0db7674dac3",
|
||||||
"version" : "0.50.6"
|
"version" : "0.54.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftradix",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/orchetect/SwiftRadix",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "a52c37a4c213403f7377ae77b4c68451bcab8330",
|
||||||
|
"version" : "1.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,5 +146,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
// swift-tools-version:5.7
|
// swift-tools-version:5.10
|
||||||
|
|
||||||
import PackageDescription
|
import PackageDescription
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "Tart",
|
name: "Tart",
|
||||||
platforms: [
|
platforms: [
|
||||||
.macOS(.v12)
|
.macOS(.v13)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
.executable(name: "tart", targets: ["tart"])
|
.executable(name: "tart", targets: ["tart"])
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.1"),
|
||||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
|
||||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "7.0.0"),
|
||||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
.package(url: "https://github.com/antlr/antlr4", exact: "4.13.2"),
|
||||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.2.0")),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.53.6"),
|
||||||
.package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
|
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.36.0"),
|
||||||
.package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.3.3"),
|
|
||||||
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
.package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
|
||||||
|
.package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.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: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
|
||||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
@@ -32,6 +35,11 @@ let package = Package(
|
|||||||
.product(name: "Atomics", package: "swift-atomics"),
|
.product(name: "Atomics", package: "swift-atomics"),
|
||||||
.product(name: "Sentry", package: "sentry-cocoa"),
|
.product(name: "Sentry", package: "sentry-cocoa"),
|
||||||
.product(name: "TextTable", package: "TextTable"),
|
.product(name: "TextTable", package: "TextTable"),
|
||||||
|
.product(name: "Sysctl", package: "swift-sysctl"),
|
||||||
|
.product(name: "SwiftRadix", package: "SwiftRadix"),
|
||||||
|
.product(name: "Semaphore", package: "Semaphore"),
|
||||||
|
.product(name: "DMRetry", package: "swift-retry"),
|
||||||
|
.product(name: "XAttr", package: "swift-xattr"),
|
||||||
], exclude: [
|
], exclude: [
|
||||||
"OCI/Reference/Makefile",
|
"OCI/Reference/Makefile",
|
||||||
"OCI/Reference/Reference.g4",
|
"OCI/Reference/Reference.g4",
|
||||||
|
|||||||
@@ -6,33 +6,34 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
|
||||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||||
* Use Tart Packer Plugin to automate VM creation.
|
* Use Tart Packer Plugin to automate VM creation.
|
||||||
* Built-in CI integration.
|
* Easily integrates with any CI system.
|
||||||
|
|
||||||
*Tart* is already adopted by several automation services:
|
Tart powers [Cirrus Runners](https://cirrus-runners.app/)
|
||||||
|
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
<a href="https://cirrus-runners.app/?utm_source=github&utm_medium=referral" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
|
||||||
</a>
|
|
||||||
<a href="https://codemagic.io/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
|
||||||
</a>
|
|
||||||
<a href="https://testingbot.com/" target=_blank>
|
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Many more companies are using Tart in their internal setups. Here are a few of them:
|
Many companies are using Tart in their internal setups. Here are just a few of them:
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://ahrefs.com/" target=_blank>
|
<a href="https://atlassian.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Atlassian.png" height="65"/>
|
||||||
|
</a>
|
||||||
|
<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>
|
||||||
<a href="https://krisp.ai/" target=_blank>
|
<a href="https://krisp.ai/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Krisp.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://suran.com/" target=_blank>
|
<a href="https://testingbot.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://symflower.com/" target=_blank>
|
<a href="https://symflower.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
|
||||||
@@ -40,18 +41,33 @@ Many more companies are using Tart in their internal setups. Here are a few of t
|
|||||||
<a href="https://transloadit.com/" target=_blank>
|
<a href="https://transloadit.com/" target=_blank>
|
||||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Transloadit.png" height="65"/>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="https://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>
|
</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
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
tart clone ghcr.io/cirruslabs/macos-sonoma-base:latest sonoma-base
|
||||||
tart run ventura-base
|
tart run sonoma-base
|
||||||
```
|
```
|
||||||
|
|
||||||
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,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>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.developer-tools</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: 7.0 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -4,5 +4,7 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.virtualization</key>
|
<key>com.apple.security.virtualization</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>com.apple.security.get-task-allow</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ import Foundation
|
|||||||
import SystemConfiguration
|
import SystemConfiguration
|
||||||
|
|
||||||
struct Clone: AsyncParsableCommand {
|
struct Clone: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Clone a VM")
|
static var configuration = CommandConfiguration(
|
||||||
|
abstract: "Clone a VM",
|
||||||
|
discussion: """
|
||||||
|
Creates a local virtual machine by cloning either a remote or another local virtual machine.
|
||||||
|
|
||||||
@Argument(help: "source VM name")
|
Due to copy-on-write magic in Apple File System, a cloned VM won't actually claim all the space right away.
|
||||||
|
Only changes to a cloned disk will be written and claim new space. This also speeds up clones enormously.
|
||||||
|
|
||||||
|
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", completion: .custom(completeMachines))
|
||||||
var sourceName: String
|
var sourceName: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -14,10 +25,20 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var deduplicate: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -27,11 +48,10 @@ struct Clone: AsyncParsableCommand {
|
|||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||||
|
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporary()
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
@@ -44,10 +64,21 @@ struct Clone: AsyncParsableCommand {
|
|||||||
try lock.lock()
|
try lock.lock()
|
||||||
|
|
||||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||||
|
&& sourceVM.state() != .Suspended
|
||||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||||
|
|
||||||
try localStorage.move(newName, from: tmpVMDir)
|
try localStorage.move(newName, from: tmpVMDir)
|
||||||
|
|
||||||
try lock.unlock()
|
try lock.unlock()
|
||||||
|
|
||||||
|
// APFS is doing copy-on-write, so the above cloning operation (just copying files on disk)
|
||||||
|
// is not actually claiming new space until the VM is started and it writes something to disk.
|
||||||
|
//
|
||||||
|
// So, once we clone the VM let's try to claim the rest of space for the VM to run without errors.
|
||||||
|
let unallocatedBytes = try sourceVM.sizeBytes() - sourceVM.allocatedSizeBytes()
|
||||||
|
if unallocatedBytes > 0 {
|
||||||
|
try Prune.reclaimIfNeeded(UInt64(unallocatedBytes), sourceVM)
|
||||||
|
}
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Dispatch
|
import Dispatch
|
||||||
import SwiftUI
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Create: AsyncParsableCommand {
|
struct Create: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
static var configuration = CommandConfiguration(abstract: "Create a VM")
|
||||||
@@ -15,13 +16,18 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Flag(help: "create a Linux VM")
|
@Flag(help: "create a Linux VM")
|
||||||
var linux: Bool = false
|
var linux: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||||
var diskSize: UInt16 = 50
|
var diskSize: UInt16 = 50
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil && !linux {
|
if fromIPSW == nil && !linux {
|
||||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||||
}
|
}
|
||||||
|
#if arch(x86_64)
|
||||||
|
if fromIPSW != nil {
|
||||||
|
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -32,26 +38,32 @@ struct Create: AsyncParsableCommand {
|
|||||||
try tmpVMDirLock.lock()
|
try tmpVMDirLock.lock()
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
if let fromIPSW = fromIPSW {
|
#if arch(arm64)
|
||||||
let ipswURL: URL
|
if let fromIPSW = fromIPSW {
|
||||||
|
let ipswURL: URL
|
||||||
|
|
||||||
if fromIPSW == "latest" {
|
if fromIPSW == "latest" {
|
||||||
ipswURL = try await VM.latestIPSWURL()
|
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
||||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
|
||||||
ipswURL = URL(string: fromIPSW)!
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
} else {
|
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
||||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipswURL = image.url
|
||||||
|
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||||
|
ipswURL = URL(string: fromIPSW)!
|
||||||
|
} else {
|
||||||
|
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
if linux {
|
if linux {
|
||||||
if #available(macOS 13, *) {
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SwiftUI
|
|||||||
struct Delete: AsyncParsableCommand {
|
struct Delete: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
var name: [String]
|
var name: [String]
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
struct Export: AsyncParsableCommand {
|
struct Export: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
|
||||||
|
|
||||||
@Argument(help: "Source VM name.")
|
@Argument(help: "Source VM name.", completion: .custom(completeMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "Path to the destination file.")
|
@Argument(help: "Path to the destination file.")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import SystemConfiguration
|
||||||
|
|
||||||
|
struct FQN: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Get a fully-qualified VM name", shouldDisplay: false)
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
if var remoteName = try? RemoteName(name) {
|
||||||
|
let digest = try VMStorageOCI().digest(remoteName)
|
||||||
|
|
||||||
|
remoteName.reference = Reference(digest: digest)
|
||||||
|
|
||||||
|
print(remoteName)
|
||||||
|
} else {
|
||||||
|
print(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,20 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
|
let OS: OS
|
||||||
let CPU: Int
|
let CPU: Int
|
||||||
let Memory: UInt64
|
let Memory: UInt64
|
||||||
let Disk: Int
|
let Disk: Int
|
||||||
|
let Size: String
|
||||||
let Display: String
|
let Display: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
|
let State: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Get: AsyncParsableCommand {
|
struct Get: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name.")
|
@Argument(help: "VM name.", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Output format: text or json")
|
@Option(help: "Output format: text or json")
|
||||||
@@ -21,11 +24,9 @@ struct Get: AsyncParsableCommand {
|
|||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let diskSizeInGb = try vmDir.sizeGB()
|
|
||||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
|
||||||
let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
|
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||||
Display: vmConfig.display.description, Running: try vmDir.running())
|
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
|
|||||||
struct IP: AsyncParsableCommand {
|
struct IP: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||||
@@ -35,8 +35,19 @@ struct IP: AsyncParsableCommand {
|
|||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
|
||||||
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
|
||||||
throw RuntimeError.NoIPAddressFound("no IP address found, is your VM running?")
|
var message = "no IP address found"
|
||||||
|
|
||||||
|
if try !vmDir.running() {
|
||||||
|
message += ", is your VM running?"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vmConfig.os == .linux && resolver == .arp) {
|
||||||
|
message += " (not all Linux distributions are compatible with the ARP resolver)"
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.NoIPAddressFound(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
print(ip)
|
print(ip)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +61,7 @@ struct IP: AsyncParsableCommand {
|
|||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
case .dhcp:
|
case .dhcp:
|
||||||
if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
if let leases = try Leases(), let ip = leases.ResolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import SwiftUI
|
|||||||
fileprivate struct VMInfo: Encodable {
|
fileprivate struct VMInfo: Encodable {
|
||||||
let Source: String
|
let Source: String
|
||||||
let Name: String
|
let Name: String
|
||||||
|
let Disk: Int
|
||||||
let Size: Int
|
let Size: Int
|
||||||
|
let SizeOnDisk: Int
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
|
let State: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
@@ -36,13 +39,13 @@ struct List: AsyncParsableCommand {
|
|||||||
|
|
||||||
if source == nil || source == "local" {
|
if source == nil || source == "local" {
|
||||||
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
|
||||||
try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running())
|
try VMInfo(Source: "local", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if source == nil || source == "oci" {
|
if source == nil || source == "oci" {
|
||||||
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
|
||||||
try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running())
|
try VMInfo(Source: "OCI", Name: name, Disk: vmDir.sizeGB(), Size: vmDir.allocatedSizeGB(), SizeOnDisk: vmDir.allocatedSizeGB() - vmDir.deduplicatedSizeGB(), Running: vmDir.running(), State: vmDir.state().rawValue)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Flag(help: "skip validation of the registry's credentials before logging-in")
|
||||||
|
var noValidate: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
let usernameProvided = username != nil
|
let usernameProvided = username != nil
|
||||||
let passwordProvided = passwordStdin
|
let passwordProvided = passwordStdin
|
||||||
@@ -35,6 +38,9 @@ struct Login: AsyncParsableCommand {
|
|||||||
|
|
||||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||||
password = String(decoding: passwordData, as: UTF8.self)
|
password = String(decoding: passwordData, as: UTF8.self)
|
||||||
|
|
||||||
|
// Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
|
||||||
|
password.trimSuffix { c in c.isNewline }
|
||||||
} else {
|
} else {
|
||||||
(user, password) = try StdinCredentials.retrieve()
|
(user, password) = try StdinCredentials.retrieve()
|
||||||
}
|
}
|
||||||
@@ -42,12 +48,15 @@ struct Login: AsyncParsableCommand {
|
|||||||
host: (user, password)
|
host: (user, password)
|
||||||
])
|
])
|
||||||
|
|
||||||
do {
|
if !noValidate {
|
||||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||||
credentialsProviders: [credentialsProvider])
|
credentialsProviders: [credentialsProvider])
|
||||||
try await registry.ping()
|
|
||||||
} catch {
|
do {
|
||||||
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
try await registry.ping()
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Dispatch
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct Logout: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
|
||||||
|
|
||||||
|
@Argument(help: "host")
|
||||||
|
var host: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
try KeychainCredentialsProvider().remove(host: host)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,40 @@ import SwiftUI
|
|||||||
import SwiftDate
|
import SwiftDate
|
||||||
|
|
||||||
struct Prune: AsyncParsableCommand {
|
struct Prune: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
|
||||||
|
var entries: String = "caches"
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
|
||||||
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var olderThan: UInt?
|
var olderThan: UInt?
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
@Option(help: .hidden)
|
||||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
|
||||||
valueName: "n"))
|
|
||||||
var cacheBudget: UInt?
|
var cacheBudget: UInt?
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
|
||||||
|
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
|
||||||
|
valueName: "n"))
|
||||||
|
var spaceBudget: UInt?
|
||||||
|
|
||||||
@Flag(help: .hidden)
|
@Flag(help: .hidden)
|
||||||
var gc: Bool = false
|
var gc: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
mutating func validate() throws {
|
||||||
if olderThan == nil && cacheBudget == nil && !gc {
|
// --cache-budget deprecation logic
|
||||||
|
if let cacheBudget = cacheBudget {
|
||||||
|
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
|
||||||
|
|
||||||
|
if spaceBudget != nil {
|
||||||
|
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
spaceBudget = cacheBudget
|
||||||
|
}
|
||||||
|
|
||||||
|
if olderThan == nil && spaceBudget == nil && !gc {
|
||||||
throw ValidationError("at least one pruning criteria must be specified")
|
throw ValidationError("at least one pruning criteria must be specified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
|
|||||||
try VMStorageOCI().gc()
|
try VMStorageOCI().gc()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a list of prunable storages that we're going to prune based on user's request
|
||||||
|
let prunableStorages: [PrunableStorage]
|
||||||
|
|
||||||
|
switch entries {
|
||||||
|
case "caches":
|
||||||
|
prunableStorages = [VMStorageOCI(), try IPSWCache()]
|
||||||
|
case "vms":
|
||||||
|
prunableStorages = [VMStorageLocal()]
|
||||||
|
default:
|
||||||
|
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up cache entries based on last accessed date
|
// Clean up cache entries based on last accessed date
|
||||||
if let olderThan = olderThan {
|
if let olderThan = olderThan {
|
||||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
let olderThanDate = Date() - olderThanInterval
|
||||||
|
|
||||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||||
if let cacheBudget = cacheBudget {
|
if let spaceBudget = spaceBudget {
|
||||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
|
||||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||||
|
|
||||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||||
|
|
||||||
var cacheBudgetBytes = cacheBudgetBytes
|
var spaceBudgetBytes = spaceBudgetBytes
|
||||||
var prunablesToDelete: [Prunable] = []
|
var prunablesToDelete: [Prunable] = []
|
||||||
|
|
||||||
for prunable in prunables {
|
for prunable in prunables {
|
||||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
||||||
|
|
||||||
if prunableSizeBytes <= cacheBudgetBytes {
|
if prunableSizeBytes <= spaceBudgetBytes {
|
||||||
// Don't mark for deletion as
|
// Don't mark for deletion as
|
||||||
// there's a budget available
|
// there's a budget available
|
||||||
cacheBudgetBytes -= prunableSizeBytes
|
spaceBudgetBytes -= prunableSizeBytes
|
||||||
} else {
|
} else {
|
||||||
// Mark for deletion
|
// Mark for deletion
|
||||||
prunablesToDelete.append(prunable)
|
prunablesToDelete.append(prunable)
|
||||||
@@ -77,14 +104,61 @@ struct Prune: AsyncParsableCommand {
|
|||||||
try prunablesToDelete.forEach { try $0.delete() }
|
try prunablesToDelete.forEach { try $0.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Figure out how much disk space is available
|
||||||
|
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
||||||
|
.volumeAvailableCapacityKey,
|
||||||
|
.volumeAvailableCapacityForImportantUsageKey
|
||||||
|
])
|
||||||
|
let volumeAvailableCapacityCalculated = max(
|
||||||
|
UInt64(attrs.volumeAvailableCapacity!),
|
||||||
|
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||||
|
)
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: [
|
||||||
|
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
||||||
|
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
||||||
|
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
||||||
|
], key: "Prune")
|
||||||
|
}
|
||||||
|
|
||||||
|
if volumeAvailableCapacityCalculated <= 0 {
|
||||||
|
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
||||||
|
scope.setLevel(.warning)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that we know how much free space is left,
|
||||||
|
// check if we even need to reclaim anything
|
||||||
|
if requiredBytes < volumeAvailableCapacityCalculated {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
||||||
|
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
||||||
|
defer { transaction.finish() }
|
||||||
|
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
// Does it even make sense to start?
|
// Does it even make sense to start?
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
||||||
if cacheUsedBytes < reclaimBytes {
|
if cacheUsedBytes < reclaimBytes {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,10 +172,16 @@ struct Prune: AsyncParsableCommand {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
||||||
try prunable.delete()
|
// do not prune the initiator
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try SentrySDK.span?.setExtra(value: prunable.sizeBytes(), key: prunable.url.path);
|
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
||||||
|
|
||||||
|
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
||||||
|
|
||||||
|
try prunable.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
||||||
|
|||||||
@@ -3,7 +3,16 @@ import Dispatch
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Pull: AsyncParsableCommand {
|
struct Pull: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
|
static var configuration = CommandConfiguration(
|
||||||
|
abstract: "Pull a VM from a registry",
|
||||||
|
discussion: """
|
||||||
|
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
|
||||||
|
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
|
||||||
|
|
||||||
|
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image
|
||||||
|
to fit. This behaviour is called "automatic pruning" and can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
@Argument(help: "remote VM name")
|
@Argument(help: "remote VM name")
|
||||||
var remoteName: String
|
var remoteName: String
|
||||||
@@ -11,6 +20,18 @@ struct Pull: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when pulling a remote VM from the OCI-compatible registry")
|
||||||
|
var concurrency: UInt = 4
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var deduplicate: Bool = false
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if concurrency < 1 {
|
||||||
|
throw ValidationError("network concurrency cannot be less than 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
// see https://github.com/cirruslabs/tart/issues/36
|
// see https://github.com/cirruslabs/tart/issues/36
|
||||||
@@ -25,6 +46,6 @@ struct Pull: AsyncParsableCommand {
|
|||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||||
|
|
||||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
try await VMStorageOCI().pull(remoteName, registry: registry, concurrency: concurrency, deduplicate: deduplicate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Compression
|
|||||||
struct Push: AsyncParsableCommand {
|
struct Push: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||||
|
|
||||||
@Argument(help: "local VM name")
|
@Argument(help: "local or remote VM name", completion: .custom(completeMachines))
|
||||||
var localName: String
|
var localName: String
|
||||||
|
|
||||||
@Argument(help: "remote VM name(s)")
|
@Argument(help: "remote VM name(s)")
|
||||||
@@ -15,6 +15,9 @@ struct Push: AsyncParsableCommand {
|
|||||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
|
@Option(help: "network concurrency to use when 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",
|
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||||
@@ -23,12 +26,20 @@ struct Push: AsyncParsableCommand {
|
|||||||
"""))
|
"""))
|
||||||
var chunkSize: Int = 0
|
var chunkSize: Int = 0
|
||||||
|
|
||||||
|
@Option(help: .hidden)
|
||||||
|
var diskFormat: String = "v2"
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||||
var populateCache: Bool = false
|
var populateCache: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let localVMDir = try VMStorageLocal().open(localName)
|
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
|
// Parse remote names supplied by the user
|
||||||
let remoteNames = try remoteNames.map{
|
let remoteNames = try remoteNames.map{
|
||||||
@@ -53,23 +64,57 @@ struct Push: AsyncParsableCommand {
|
|||||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||||
|
|
||||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
let references = remoteNamesForRegistry.map{ $0.reference.value }
|
||||||
registry: registry,
|
|
||||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
|
||||||
chunkSizeMb: chunkSize
|
|
||||||
)
|
|
||||||
|
|
||||||
// Populate the local cache (if requested)
|
let pushedRemoteName: RemoteName
|
||||||
|
// If we're pushing a local OCI VM, check if points to an already existing registry manifest
|
||||||
|
// and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
|
||||||
|
if let remoteName = try? RemoteName(localName) {
|
||||||
|
pushedRemoteName = try await lightweightPushToRegistry(
|
||||||
|
registry: registry,
|
||||||
|
remoteName: remoteName,
|
||||||
|
references: references
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||||
|
registry: registry,
|
||||||
|
references: references,
|
||||||
|
chunkSizeMb: chunkSize,
|
||||||
|
diskFormat: diskFormat,
|
||||||
|
concurrency: concurrency
|
||||||
|
)
|
||||||
|
// Populate the local cache (if requested)
|
||||||
|
if populateCache {
|
||||||
|
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
||||||
|
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// link the rest remote names
|
||||||
if populateCache {
|
if populateCache {
|
||||||
let ociStorage = VMStorageOCI()
|
|
||||||
let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
|
|
||||||
try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
|
|
||||||
for remoteName in remoteNamesForRegistry {
|
for remoteName in remoteNamesForRegistry {
|
||||||
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
try ociStorage.link(from: remoteName, to: pushedRemoteName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
|
||||||
|
// Is the local OCI VM already present in the registry?
|
||||||
|
let digest = try VMStorageOCI().digest(remoteName)
|
||||||
|
|
||||||
|
let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
|
||||||
|
|
||||||
|
// Overwrite registry's references with the retrieved manifest
|
||||||
|
for reference in references {
|
||||||
|
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||||
|
|
||||||
|
_ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RemoteName(host: registry.host!, namespace: registry.namespace,
|
||||||
|
reference: Reference(digest: digest))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Collection where Element == RemoteName {
|
extension Collection where Element == RemoteName {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Rename: AsyncParsableCommand {
|
struct Rename: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
static var configuration = CommandConfiguration(abstract: "Rename a local VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
@@ -20,11 +20,11 @@ struct Rename: AsyncParsableCommand {
|
|||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
if !localStorage.exists(name) {
|
if !localStorage.exists(name) {
|
||||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
throw ValidationError("failed to rename a non-existent local VM: \(name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if localStorage.exists(newName) {
|
if localStorage.exists(newName) {
|
||||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
throw ValidationError("failed to rename VM \(name), target VM \(newName) already exists, delete it first!")
|
||||||
}
|
}
|
||||||
|
|
||||||
try localStorage.rename(name, newName)
|
try localStorage.rename(name, newName)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
struct Set: AsyncParsableCommand {
|
struct Set: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeLocalMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(help: "Number of VM CPUs")
|
@Option(help: "Number of VM CPUs")
|
||||||
@@ -16,7 +17,32 @@ struct Set: AsyncParsableCommand {
|
|||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
|
|
||||||
@Option(help: .hidden)
|
@Flag(inversion: .prefixedNo, help: ArgumentHelp("Whether to automatically reconfigure the VM's display to fit the window"))
|
||||||
|
var displayRefit: Bool? = nil
|
||||||
|
|
||||||
|
@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?
|
var diskSize: UInt16?
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -40,8 +66,29 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vmConfig.displayRefit = displayRefit
|
||||||
|
|
||||||
|
if randomMAC {
|
||||||
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if arch(arm64)
|
||||||
|
if randomSerial {
|
||||||
|
let oldPlatform = vmConfig.platform as! Darwin
|
||||||
|
vmConfig.platform = Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: oldPlatform.hardwareModel)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
try vmConfig.save(toURL: vmDir.configURL)
|
try vmConfig.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
if 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 {
|
if diskSize != nil {
|
||||||
try vmDir.resizeDisk(diskSize!)
|
try vmDir.resizeDisk(diskSize!)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import SwiftDate
|
|||||||
struct Stop: AsyncParsableCommand {
|
struct Stop: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||||
@@ -14,12 +14,27 @@ struct Stop: AsyncParsableCommand {
|
|||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let lock = try PIDLock(lockURL: vmDir.configURL)
|
switch try vmDir.state() {
|
||||||
|
case .Suspended:
|
||||||
|
try stopSuspended(vmDir)
|
||||||
|
case .Running:
|
||||||
|
try await stopRunning(vmDir)
|
||||||
|
case .Stopped:
|
||||||
|
throw RuntimeError.VMNotRunning(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSuspended(_ vmDir: VMDirectory) throws {
|
||||||
|
try? FileManager.default.removeItem(at: vmDir.stateURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopRunning(_ vmDir: VMDirectory) async throws {
|
||||||
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
// Find the VM's PID
|
// Find the VM's PID
|
||||||
var pid = try lock.pid()
|
var pid = try lock.pid()
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
throw RuntimeError.VMNotRunning(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to gracefully terminate the VM
|
// Try to gracefully terminate the VM
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
struct Suspend: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
|
||||||
|
|
||||||
|
@Argument(help: "VM name", completion: .custom(completeRunningMachines))
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let lock = try vmDir.lock()
|
||||||
|
|
||||||
|
// Find the VM's PID
|
||||||
|
let pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the "tart run" process to suspend the VM
|
||||||
|
let ret = kill(pid, SIGUSR1)
|
||||||
|
if ret != 0 {
|
||||||
|
throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||||
return credentialsFromAuth
|
return credentialsFromAuth
|
||||||
}
|
}
|
||||||
if let helperProgram = config.credHelpers?[host] {
|
if let helperProgram = try config.findCredHelper(host: host) {
|
||||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,15 +39,21 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||||
inPipe.fileHandleForWriting.closeFile()
|
inPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
|
let outputData = try outPipe.fileHandleForReading.readToEnd()
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
if let outputData = outputData {
|
||||||
|
print(String(decoding: outputData, as: UTF8.self))
|
||||||
|
}
|
||||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||||
}
|
}
|
||||||
|
if outputData == nil || outputData?.count == 0 {
|
||||||
|
throw CredentialsProviderError.Failed(message: "Docker helper output is empty!")
|
||||||
|
}
|
||||||
|
|
||||||
let getOutput = try JSONDecoder().decode(
|
let getOutput = try JSONDecoder().decode(DockerGetOutput.self, from: outputData!)
|
||||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
)
|
|
||||||
return (getOutput.Username, getOutput.Secret)
|
return (getOutput.Username, getOutput.Secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +65,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
struct DockerConfig: Codable {
|
struct DockerConfig: Codable {
|
||||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||||
|
|
||||||
|
func findCredHelper(host: String) throws -> String? {
|
||||||
|
// Tart supports wildcards in credHelpers
|
||||||
|
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
|
||||||
|
|
||||||
|
guard let credHelpers else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for (hostPattern, helperProgram) in credHelpers {
|
||||||
|
if (hostPattern == host) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
let compiledPattern = try? Regex(hostPattern)
|
||||||
|
if (try compiledPattern?.wholeMatch(in: host) != nil) {
|
||||||
|
return helperProgram
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DockerAuthConfig: Codable {
|
struct DockerAuthConfig: Codable {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import Foundation
|
|||||||
|
|
||||||
class EnvironmentCredentialsProvider: CredentialsProvider {
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
|
if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
|
||||||
|
tartRegistryHostname != host {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
||||||
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||||
if let username = username, let password = password {
|
if let username = username, let password = password {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
kSecAttrLabel as String: "Tart Credentials",
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
]
|
]
|
||||||
let value: [String: Any] = [kSecAttrAccount as String: user,
|
let value: [String: Any] = [kSecAttrAccount as String: user,
|
||||||
kSecValueData as String: passwordData,
|
kSecValueData as String: passwordData as Any,
|
||||||
]
|
]
|
||||||
|
|
||||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||||
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
|||||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remove(host: String) throws {
|
||||||
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
|
kSecAttrServer as String: host,
|
||||||
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
|
]
|
||||||
|
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
|
||||||
|
switch status {
|
||||||
|
case errSecSuccess:
|
||||||
|
return
|
||||||
|
case errSecItemNotFound:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OSStatus {
|
extension OSStatus {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class StdinCredentials {
|
|||||||
return (user, password)
|
return (user, password)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 1024, isSensitive: Bool) throws -> String {
|
||||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||||
throw StdinCredentialsError.CredentialRequired(which: name)
|
throw StdinCredentialsError.CredentialRequired(which: name)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
import Sysctl
|
||||||
|
|
||||||
|
class DeviceInfo {
|
||||||
|
private static var osMemoized: String? = nil
|
||||||
|
private static var modelMemoized: String? = nil
|
||||||
|
|
||||||
|
static var os: String {
|
||||||
|
if let os = osMemoized {
|
||||||
|
return os
|
||||||
|
}
|
||||||
|
|
||||||
|
osMemoized = getOS()
|
||||||
|
|
||||||
|
return osMemoized!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var model: String {
|
||||||
|
if let model = modelMemoized {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
modelMemoized = getModel()
|
||||||
|
|
||||||
|
return modelMemoized!
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getOS() -> String {
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
|
||||||
|
return "macOS \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func getModel() -> String {
|
||||||
|
return SystemControl().hardware.model
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,56 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AsyncAlgorithms
|
|
||||||
|
|
||||||
fileprivate let urlSession = createURLSession()
|
fileprivate var urlSessionConfiguration: URLSessionConfiguration {
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
|
||||||
|
|
||||||
let (data, response) = try await urlSession.data(for: request)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await dataCh.send(data)
|
|
||||||
|
|
||||||
dataCh.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (dataCh, response as! HTTPURLResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
|
||||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
|
||||||
|
|
||||||
let (fileURL, response) = try await urlSession.download(for: request)
|
|
||||||
|
|
||||||
// Acquire a handle to the downloaded file and then remove it.
|
|
||||||
//
|
|
||||||
// This keeps a working reference to that file, yet we don't
|
|
||||||
// have to deal with the cleanup any more.
|
|
||||||
let fh = try FileHandle(forReadingFrom: fileURL)
|
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
|
||||||
|
|
||||||
Task {
|
|
||||||
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
|
||||||
await dataCh.send(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
dataCh.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (dataCh, response as! HTTPURLResponse)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate func createURLSession() -> URLSession {
|
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
|
|
||||||
// Harbor expects a CSRF token to be present if the HTTP client
|
// Harbor expects a CSRF token to be present if the HTTP client
|
||||||
@@ -63,5 +13,84 @@ fileprivate func createURLSession() -> URLSession {
|
|||||||
// [2]: https://github.com/cirruslabs/tart/issues/295
|
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||||
config.httpShouldSetCookies = false
|
config.httpShouldSetCookies = false
|
||||||
|
|
||||||
return URLSession(configuration: config)
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false, progress: Progress? = nil) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
|
let delegate = Delegate()
|
||||||
|
let session = URLSession(configuration: urlSessionConfiguration, delegate: delegate, delegateQueue: nil)
|
||||||
|
let task = session.dataTask(with: request)
|
||||||
|
|
||||||
|
let stream = AsyncThrowingStream<Data, Error> { continuation in
|
||||||
|
delegate.streamContinuation = continuation
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await withCheckedThrowingContinuation { continuation in
|
||||||
|
delegate.responseContinuation = continuation
|
||||||
|
task.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (stream, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate class Delegate: NSObject, URLSessionDataDelegate {
|
||||||
|
var responseContinuation: CheckedContinuation<URLResponse, Error>?
|
||||||
|
var streamContinuation: AsyncThrowingStream<Data, Error>.Continuation?
|
||||||
|
|
||||||
|
private var buffer: Data = Data()
|
||||||
|
private let bufferFlushSize = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive response: URLResponse,
|
||||||
|
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
||||||
|
) {
|
||||||
|
// Soft-limit for the maximum buffer capacity
|
||||||
|
let capacity = min(response.expectedContentLength, Int64(bufferFlushSize))
|
||||||
|
|
||||||
|
// Pre-initialize buffer as we now know the capacity
|
||||||
|
buffer = Data(capacity: Int(capacity))
|
||||||
|
|
||||||
|
responseContinuation?.resume(returning: response)
|
||||||
|
responseContinuation = nil
|
||||||
|
completionHandler(.allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive data: Data
|
||||||
|
) {
|
||||||
|
buffer.append(data)
|
||||||
|
|
||||||
|
if buffer.count >= bufferFlushSize {
|
||||||
|
streamContinuation?.yield(buffer)
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
task: URLSessionTask,
|
||||||
|
didCompleteWithError error: Error?
|
||||||
|
) {
|
||||||
|
if let error = error {
|
||||||
|
responseContinuation?.resume(throwing: error)
|
||||||
|
responseContinuation = nil
|
||||||
|
|
||||||
|
streamContinuation?.finish(throwing: error)
|
||||||
|
streamContinuation = nil
|
||||||
|
} else {
|
||||||
|
if !buffer.isEmpty {
|
||||||
|
streamContinuation?.yield(buffer)
|
||||||
|
buffer.removeAll(keepingCapacity: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
streamContinuation?.finish()
|
||||||
|
streamContinuation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,16 @@ enum Format: String, ExpressibleByArgument, CaseIterable {
|
|||||||
}
|
}
|
||||||
let table = TextTable<T> { (item: T) in
|
let table = TextTable<T> { (item: T) in
|
||||||
let mirroredObject = Mirror(reflecting: item)
|
let mirroredObject = Mirror(reflecting: item)
|
||||||
return mirroredObject.children.enumerated().map { (_, element) in
|
return mirroredObject.children.enumerated()
|
||||||
let fieldName = element.label!
|
.filter {(_, element) in
|
||||||
return Column(title: fieldName, value: element.value)
|
// Deprecate the "Running" field: only make it available
|
||||||
}
|
// from JSON for backwards-compatibility
|
||||||
|
element.label! != "Running"
|
||||||
|
}
|
||||||
|
.map { (_, element) in
|
||||||
|
let fieldName = element.label!
|
||||||
|
return Column(title: fieldName, value: element.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
case .json:
|
case .json:
|
||||||
|
|||||||
@@ -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
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
|
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
|
}
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
@@ -60,10 +65,6 @@ struct ARPCache {
|
|||||||
terminationStatus: process.terminationStatus)
|
terminationStatus: process.terminationStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
|
|
||||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
|
||||||
}
|
|
||||||
|
|
||||||
self.arpCommandOutput = arpCommandOutput
|
self.arpCommandOutput = arpCommandOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
struct Lease {
|
struct Lease {
|
||||||
var mac: MACAddress
|
var mac: MACAddress
|
||||||
var ip: IPv4Address
|
var ip: IPv4Address
|
||||||
|
var expiresAt: Date
|
||||||
|
|
||||||
init?(fromRawLease: [String : String]) {
|
init?(fromRawLease: [String : String]) {
|
||||||
// Retrieve the required fields
|
// Retrieve the required fields
|
||||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||||
|
guard let lease = fromRawLease["lease"] else { return nil }
|
||||||
|
|
||||||
// Parse MAC address
|
// Parse MAC address
|
||||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||||
@@ -26,7 +30,13 @@ struct Lease {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse expiration timestamp
|
||||||
|
guard let leaseTimestamp = lease.hex?.value else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
self.ip = ip
|
self.ip = ip
|
||||||
self.mac = mac
|
self.mac = mac
|
||||||
|
self.expiresAt = Date(timeIntervalSince1970: TimeInterval(leaseTimestamp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,13 +37,18 @@ class Leases {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(_ fromString: String) throws {
|
init(_ fromString: String) throws {
|
||||||
var leases: [MACAddress : Lease] = Dictionary()
|
let leases = try Self.retrieveRawLeases(fromString).compactMap({ rawLease in
|
||||||
|
Lease(fromRawLease: rawLease)
|
||||||
|
}).filter({ lease in
|
||||||
|
lease.expiresAt.isInFuture
|
||||||
|
}).map({ lease in
|
||||||
|
(lease.mac, lease)
|
||||||
|
})
|
||||||
|
|
||||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
self.leases = Dictionary(leases) { (left, right) in
|
||||||
leases[lease.mac] = lease
|
// When duplicate lease is found, prefer a newer lease over the older one
|
||||||
|
(left.expiresAt > right.expiresAt) ? left : right
|
||||||
}
|
}
|
||||||
|
|
||||||
self.leases = leases
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||||
@@ -107,7 +112,7 @@ class Leases {
|
|||||||
return rawLeases
|
return rawLeases
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
func ResolveMACAddress(macAddress: MACAddress) -> IPv4Address? {
|
||||||
leases[macAddress]?.ip
|
leases[macAddress]?.ip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
import Semaphore
|
||||||
|
|
||||||
protocol Network {
|
protocol Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment
|
func attachments() -> [VZNetworkDeviceAttachment]
|
||||||
func run(_ sema: DispatchSemaphore) throws
|
func run(_ sema: AsyncSemaphore) throws
|
||||||
func stop() async throws
|
func stop() async throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkBridged: Network {
|
class NetworkBridged: Network {
|
||||||
let interface: VZBridgedNetworkInterface
|
let interfaces: [VZBridgedNetworkInterface]
|
||||||
|
|
||||||
init(interface: VZBridgedNetworkInterface) {
|
init(interfaces: [VZBridgedNetworkInterface]) {
|
||||||
self.interface = interface
|
self.interfaces = interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkShared: Network {
|
class NetworkShared: Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
VZNATNetworkDeviceAttachment()
|
[VZNATNetworkDeviceAttachment()]
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import Foundation
|
|
||||||
import Virtualization
|
|
||||||
import Atomics
|
import Atomics
|
||||||
|
import Foundation
|
||||||
|
import Semaphore
|
||||||
import System
|
import System
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
enum SoftnetError: Error {
|
enum SoftnetError: Error {
|
||||||
case InitializationFailed(why: String)
|
case InitializationFailed(why: String)
|
||||||
@@ -15,7 +16,7 @@ class Softnet: Network {
|
|||||||
|
|
||||||
let vmFD: Int32
|
let vmFD: Int32
|
||||||
|
|
||||||
init(vmMACAddress: String) throws {
|
init(vmMACAddress: String, extraArguments: [String] = []) throws {
|
||||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||||
|
|
||||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||||
@@ -30,7 +31,7 @@ class Softnet: Network {
|
|||||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||||
|
|
||||||
process.executableURL = try Self.softnetExecutableURL()
|
process.executableURL = try Self.softnetExecutableURL()
|
||||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress] + extraArguments
|
||||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ class Softnet: Network {
|
|||||||
return executableURL
|
return executableURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: AsyncSemaphore) throws {
|
||||||
try process.run()
|
try process.run()
|
||||||
|
|
||||||
monitorTask = Task {
|
monitorTask = Task {
|
||||||
@@ -92,9 +93,9 @@ class Softnet: Network {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachments() -> [VZNetworkDeviceAttachment] {
|
||||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func configureSUIDBitIfNeeded() throws {
|
static func configureSUIDBitIfNeeded() throws {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor AuthenticationKeeper {
|
||||||
|
var authentication: Authentication? = nil
|
||||||
|
|
||||||
|
func set(_ authentication: Authentication) {
|
||||||
|
self.authentication = authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
func header() -> (String, String)? {
|
||||||
|
if let authentication = authentication {
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token has expired
|
||||||
|
if !authentication.isValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return authentication.header()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not suggest any headers if the
|
||||||
|
// authentication token is not set
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
enum DigestError: Error {
|
||||||
|
case InvalidOffset
|
||||||
|
case InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
class Digest {
|
class Digest {
|
||||||
var hash: SHA256 = SHA256()
|
var hash: SHA256 = SHA256()
|
||||||
|
|
||||||
@@ -15,6 +20,37 @@ class Digest {
|
|||||||
static func hash(_ data: Data) -> String {
|
static func hash(_ data: Data) -> String {
|
||||||
SHA256.hash(data: data).hexdigest()
|
SHA256.hash(data: data).hexdigest()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL) throws -> String {
|
||||||
|
hash(try Data(contentsOf: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func hash(_ url: URL, offset: UInt64, size: UInt64) throws -> String {
|
||||||
|
// Sanity check
|
||||||
|
let fhSanity = try FileHandle(forReadingFrom: url)
|
||||||
|
try fhSanity.seekToEnd()
|
||||||
|
let fileSize = try fhSanity.offset()
|
||||||
|
try fhSanity.close()
|
||||||
|
|
||||||
|
if offset > fileSize {
|
||||||
|
throw DigestError.InvalidOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + size) > fileSize {
|
||||||
|
throw DigestError.InvalidSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read a chunk of size ``size`` at offset ``offset``
|
||||||
|
// and calculate it's digest
|
||||||
|
let fh = try FileHandle(forReadingFrom: url)
|
||||||
|
defer { try! fh.close() }
|
||||||
|
|
||||||
|
try fh.seek(toOffset: offset)
|
||||||
|
|
||||||
|
let data = try fh.read(upToCount: Int(size))!
|
||||||
|
|
||||||
|
return hash(data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SHA256.Digest {
|
extension SHA256.Digest {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
protocol Disk {
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
|
||||||
|
class DiskV1: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||||
|
|
||||||
|
static func push(diskURL: URL, registry: Registry, chunkSizeMb: Int, concurrency: UInt, progress: Progress) async throws -> [OCIManifestLayer] {
|
||||||
|
var pushedLayers: [OCIManifestLayer] = []
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
var mappedDiskReadOffset = 0
|
||||||
|
|
||||||
|
// Compress the disk file as a single stream
|
||||||
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
|
// Determine the size of the next chunk
|
||||||
|
let bytesRead = min(length, mappedDisk.count - mappedDiskReadOffset)
|
||||||
|
|
||||||
|
// Read the next uncompressed chunk
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
|
||||||
|
// Advance the offset
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
|
// Provide the uncompressed chunk to the compressing filter
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cut the compressed stream into layers, each equal exactly ``Self.layerLimitBytes`` bytes,
|
||||||
|
// except for the last one, which may be smaller
|
||||||
|
while let compressedData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||||
|
let layerDigest = try await registry.pushBlob(fromData: compressedData, chunkSizeMb: chunkSizeMb)
|
||||||
|
|
||||||
|
pushedLayers.append(OCIManifestLayer(
|
||||||
|
mediaType: diskV1MediaType,
|
||||||
|
size: compressedData.count,
|
||||||
|
digest: layerDigest
|
||||||
|
))
|
||||||
|
|
||||||
|
// Update progress using an absolute value
|
||||||
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pushedLayers
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pull(registry: Registry, diskLayers: [OCIManifestLayer], diskURL: URL, concurrency: UInt, progress: Progress, localLayerCache: LocalLayerCache? = nil, deduplicate: Bool = false) async throws {
|
||||||
|
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||||
|
throw OCIError.FailedToCreateVmFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the disk file
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
defer { try! disk.close() }
|
||||||
|
|
||||||
|
// Decompress the layers onto the disk in a single stream
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
if let data = data {
|
||||||
|
disk.write(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for diskLayer in diskLayers {
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import Foundation
|
||||||
|
import Compression
|
||||||
|
import System
|
||||||
|
import Retry
|
||||||
|
|
||||||
|
class DiskV2: Disk {
|
||||||
|
private static let bufferSizeBytes = 4 * 1024 * 1024
|
||||||
|
private static let layerLimitBytes = 512 * 1024 * 1024
|
||||||
|
|
||||||
|
// A zero chunk for faster than byte-by-byte comparisons
|
||||||
|
//
|
||||||
|
// Assumes that the other Data(...) is equal in size, but it's fine to get a false-negative
|
||||||
|
// on the last block since it costs only 4 MiB of excess data per 512 MiB 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 |
|
||||||
|
// +--------------------------------------+---------------------------------------------------+
|
||||||
|
private static let holeGranularityBytes = 4 * 1024 * 1024
|
||||||
|
private static let zeroChunk = Data(count: holeGranularityBytes)
|
||||||
|
|
||||||
|
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 mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
|
|
||||||
|
// Compress the disk file as multiple individually decompressible streams,
|
||||||
|
// each equal ``Self.layerLimitBytes`` bytes or less due to LZ4 compression
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch a disk layer pushing task
|
||||||
|
group.addTask {
|
||||||
|
let compressedData = try (data as NSData).compressed(using: .lz4) as Data
|
||||||
|
let compressedDataDigest = Digest.hash(compressedData)
|
||||||
|
|
||||||
|
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.sorted {
|
||||||
|
$0.index < $1.index
|
||||||
|
}.map {
|
||||||
|
$0.pushedLayer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
var uncompressedDiskSize: UInt64 = 0
|
||||||
|
|
||||||
|
for layer in diskLayers {
|
||||||
|
guard let uncompressedLayerSize = layer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
uncompressedDiskSize += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate the target disk file so that it will be able
|
||||||
|
// to accomodate the uncompressed disk size
|
||||||
|
let disk = try FileHandle(forWritingTo: diskURL)
|
||||||
|
try disk.truncate(atOffset: uncompressedDiskSize)
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
for (index, diskLayer) in diskLayers.enumerated() {
|
||||||
|
// Respect the concurrency limit
|
||||||
|
if index >= concurrency {
|
||||||
|
try await group.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve layer annotations
|
||||||
|
guard let uncompressedLayerSize = diskLayer.uncompressedSize() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
}
|
||||||
|
guard let uncompressedLayerContentDigest = diskLayer.uncompressedContentDigest() else {
|
||||||
|
throw OCIError.LayerIsMissingUncompressedDigestAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the current disk writing offset
|
||||||
|
let diskWritingOffset = globalDiskWritingOffset
|
||||||
|
|
||||||
|
// Launch a fetching and decompression task
|
||||||
|
group.addTask {
|
||||||
|
// No need to fetch and decompress anything if we've already done so
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskWritingOffset = diskWritingOffset
|
||||||
|
|
||||||
|
// Pull and decompress a single layer into the specific offset on disk
|
||||||
|
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||||
|
guard let data = data else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
diskWritingOffset = try zeroSkippingWrite(disk, rdisk, fsBlockSize, diskWritingOffset, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await registry.pullBlob(diskLayer.digest) { data in
|
||||||
|
try filter.write(data)
|
||||||
|
|
||||||
|
// Update the progress
|
||||||
|
progress.completedUnitCount += Int64(data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
try filter.finalize()
|
||||||
|
|
||||||
|
try disk.close()
|
||||||
|
|
||||||
|
if let rdisk = rdisk {
|
||||||
|
try rdisk.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalDiskWritingOffset += uncompressedLayerSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func zeroSkippingWrite(_ disk: FileHandle, _ rdisk: FileHandle?, _ fsBlockSize: UInt64, _ offset: UInt64, _ data: Data) throws -> UInt64 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += UInt64(chunk.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// OCI manifest and OCI config media types
|
||||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||||
|
|
||||||
// Annotations
|
// Layer media types
|
||||||
|
let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||||
|
let diskV1MediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||||
|
let diskV2MediaType = "application/vnd.cirruslabs.tart.disk.v2"
|
||||||
|
let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||||
|
|
||||||
|
// Manifest annotations
|
||||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||||
|
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||||
|
|
||||||
|
// Layer annotations
|
||||||
|
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||||
|
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||||
|
|
||||||
struct OCIManifest: Codable, Equatable {
|
struct OCIManifest: Codable, Equatable {
|
||||||
var schemaVersion: Int = 2
|
var schemaVersion: Int = 2
|
||||||
@@ -13,15 +25,21 @@ struct OCIManifest: Codable, Equatable {
|
|||||||
var layers: [OCIManifestLayer] = Array()
|
var layers: [OCIManifestLayer] = Array()
|
||||||
var annotations: Dictionary<String, String>?
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
|
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
|
||||||
self.config = config
|
self.config = config
|
||||||
self.layers = layers
|
self.layers = layers
|
||||||
|
|
||||||
|
var annotations: [String: String] = [:]
|
||||||
|
|
||||||
if let uncompressedDiskSize = uncompressedDiskSize {
|
if let uncompressedDiskSize = uncompressedDiskSize {
|
||||||
annotations = [
|
annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
|
||||||
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let uploadDate = uploadDate {
|
||||||
|
annotations[uploadTimeAnnotation] = uploadDate.toISO()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.annotations = annotations
|
||||||
}
|
}
|
||||||
|
|
||||||
init(fromJSON: Data) throws {
|
init(fromJSON: Data) throws {
|
||||||
@@ -60,10 +78,49 @@ struct OCIManifestConfig: Codable, Equatable {
|
|||||||
var digest: String
|
var digest: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct OCIManifestLayer: Codable, Equatable {
|
struct OCIManifestLayer: Codable, Equatable, Hashable {
|
||||||
var mediaType: String
|
var mediaType: String
|
||||||
var size: Int
|
var size: Int
|
||||||
var digest: String
|
var digest: String
|
||||||
|
var annotations: Dictionary<String, String>?
|
||||||
|
|
||||||
|
init(mediaType: String, size: Int, digest: String, uncompressedSize: UInt64? = nil, uncompressedContentDigest: String? = nil) {
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.size = size
|
||||||
|
self.digest = digest
|
||||||
|
|
||||||
|
var annotations: [String: String] = [:]
|
||||||
|
|
||||||
|
if let uncompressedSize = uncompressedSize {
|
||||||
|
annotations[uncompressedSizeAnnotation] = String(uncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let uncompressedContentDigest = uncompressedContentDigest {
|
||||||
|
annotations[uncompressedContentDigestAnnotation] = uncompressedContentDigest
|
||||||
|
}
|
||||||
|
|
||||||
|
self.annotations = annotations
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedSize() -> UInt64? {
|
||||||
|
guard let value = annotations?[uncompressedSizeAnnotation] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return UInt64(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func uncompressedContentDigest() -> String? {
|
||||||
|
annotations?[uncompressedContentDigestAnnotation]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
return lhs.digest == rhs.digest
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(digest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Descriptor: Equatable {
|
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
|
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
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceLexer: Lexer {
|
open class ReferenceLexer: Lexer {
|
||||||
@@ -49,7 +49,7 @@ open class ReferenceLexer: Lexer {
|
|||||||
|
|
||||||
public
|
public
|
||||||
required init(_ input: CharStream) {
|
required init(_ input: CharStream) {
|
||||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||||
super.init(input)
|
super.init(input)
|
||||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
_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
|
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
|
import Antlr4
|
||||||
|
|
||||||
open class ReferenceParser: Parser {
|
open class ReferenceParser: Parser {
|
||||||
@@ -41,7 +41,7 @@ open class ReferenceParser: Parser {
|
|||||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getGrammarFileName() -> String { return "java-escape" }
|
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||||
|
|
||||||
override open
|
override open
|
||||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||||
@@ -60,7 +60,7 @@ open class ReferenceParser: Parser {
|
|||||||
|
|
||||||
override public
|
override public
|
||||||
init(_ input:TokenStream) throws {
|
init(_ input:TokenStream) throws {
|
||||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
RuntimeMetaData.checkVersion("4.13.2", RuntimeMetaData.VERSION)
|
||||||
try super.init(input)
|
try super.init(input)
|
||||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||||
}
|
}
|
||||||
@@ -460,7 +460,7 @@ open class ReferenceParser: Parser {
|
|||||||
setState(63)
|
setState(63)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_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)
|
setState(62)
|
||||||
try separator()
|
try separator()
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ open class ReferenceParser: Parser {
|
|||||||
setState(84)
|
setState(84)
|
||||||
try _errHandler.sync(self)
|
try _errHandler.sync(self)
|
||||||
_la = try _input.LA(1)
|
_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)
|
setState(79)
|
||||||
try separator()
|
try separator()
|
||||||
setState(80)
|
setState(80)
|
||||||
@@ -664,7 +664,7 @@ open class ReferenceParser: Parser {
|
|||||||
try enterOuterAlt(_localctx, 1)
|
try enterOuterAlt(_localctx, 1)
|
||||||
setState(87)
|
setState(87)
|
||||||
_la = try _input.LA(1)
|
_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)
|
try _errHandler.recoverInline(self)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Algorithms
|
import Algorithms
|
||||||
import AsyncAlgorithms
|
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||||
@@ -10,6 +9,7 @@ enum RegistryError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum HTTPMethod: String {
|
enum HTTPMethod: String {
|
||||||
|
case HEAD = "HEAD"
|
||||||
case GET = "GET"
|
case GET = "GET"
|
||||||
case POST = "POST"
|
case POST = "POST"
|
||||||
case PUT = "PUT"
|
case PUT = "PUT"
|
||||||
@@ -21,6 +21,7 @@ enum HTTPCode: Int {
|
|||||||
case Created = 201
|
case Created = 201
|
||||||
case Accepted = 202
|
case Accepted = 202
|
||||||
case Unauthorized = 401
|
case Unauthorized = 401
|
||||||
|
case NotFound = 404
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Data {
|
extension Data {
|
||||||
@@ -29,7 +30,7 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AsyncThrowingChannel<Data, Error> {
|
extension AsyncThrowingStream<Data, Error> {
|
||||||
func asData() async throws -> Data {
|
func asData() async throws -> Data {
|
||||||
var result = Data()
|
var result = Data()
|
||||||
|
|
||||||
@@ -42,10 +43,8 @@ extension AsyncThrowingChannel<Data, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TokenResponse: Decodable, Authentication {
|
struct TokenResponse: Decodable, Authentication {
|
||||||
let defaultIssuedAt = Date()
|
var token: String?
|
||||||
let defaultExpiresIn = 60
|
var accessToken: String?
|
||||||
|
|
||||||
var token: String
|
|
||||||
var expiresIn: Int?
|
var expiresIn: Int?
|
||||||
var issuedAt: Date?
|
var issuedAt: Date?
|
||||||
|
|
||||||
@@ -65,7 +64,14 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
return dateFormatter.date(from: dateString) ?? Date()
|
return dateFormatter.date(from: dateString) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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."))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenExpiresAt: Date {
|
var tokenExpiresAt: Date {
|
||||||
@@ -78,12 +84,12 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
//
|
//
|
||||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||||
|
|
||||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
(issuedAt ?? Date()) + TimeInterval(expiresIn ?? 60)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func header() -> (String, String) {
|
func header() -> (String, String) {
|
||||||
("Authorization", "Bearer \(token)")
|
return ("Authorization", "Bearer \(token ?? accessToken ?? "")")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isValid() -> Bool {
|
func isValid() -> Bool {
|
||||||
@@ -92,17 +98,26 @@ struct TokenResponse: Decodable, Authentication {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Registry {
|
class Registry {
|
||||||
let baseURL: URL
|
private let baseURL: URL
|
||||||
let namespace: String
|
let namespace: String
|
||||||
let credentialsProviders: [CredentialsProvider]
|
let credentialsProviders: [CredentialsProvider]
|
||||||
|
let authenticationKeeper = AuthenticationKeeper()
|
||||||
|
|
||||||
var currentAuthToken: Authentication? = nil
|
var host: String? {
|
||||||
|
guard let host = baseURL.host else { return nil }
|
||||||
|
|
||||||
init(urlComponents: URLComponents,
|
if let port = baseURL.port {
|
||||||
|
return "\(host):\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
init(baseURL: URL,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
self.baseURL = baseURL
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
self.credentialsProviders = credentialsProviders
|
self.credentialsProviders = credentialsProviders
|
||||||
}
|
}
|
||||||
@@ -116,7 +131,17 @@ class Registry {
|
|||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
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 {
|
func ping() async throws {
|
||||||
@@ -165,7 +190,7 @@ class Registry {
|
|||||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
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
|
// Initiate a blob upload
|
||||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||||
headers: ["Content-Length": "0"])
|
headers: ["Content-Length": "0"])
|
||||||
@@ -177,7 +202,7 @@ class Registry {
|
|||||||
// Figure out where to upload the blob
|
// Figure out where to upload the blob
|
||||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||||
|
|
||||||
let digest = Digest.hash(fromData)
|
let digest = digest ?? Digest.hash(fromData)
|
||||||
|
|
||||||
if chunkSizeMb == 0 {
|
if chunkSizeMb == 0 {
|
||||||
// monolithic upload
|
// monolithic upload
|
||||||
@@ -225,7 +250,20 @@ class Registry {
|
|||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
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)
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await channel.asData().asText()
|
let body = try await channel.asData().asText()
|
||||||
@@ -236,7 +274,7 @@ class Registry {
|
|||||||
for try await part in channel {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(Data(part))
|
try await handler(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +306,7 @@ class Registry {
|
|||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true,
|
doAuth: Bool = true,
|
||||||
viaFile: Bool = false
|
viaFile: Bool = false
|
||||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
) async throws -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
@@ -288,17 +326,12 @@ class Registry {
|
|||||||
request.httpBody = body
|
request.httpBody = body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate token if it has expired
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile, doAuth: doAuth)
|
||||||
if currentAuthToken?.isValid() == false {
|
|
||||||
currentAuthToken = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
_ = try await channel.asData()
|
_ = try await channel.asData()
|
||||||
try await auth(response: response)
|
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)
|
return (channel, response)
|
||||||
@@ -314,7 +347,7 @@ class Registry {
|
|||||||
|
|
||||||
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials() {
|
if let (user, password) = try lookupCredentials() {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
await authenticationKeeper.set(BasicAuthentication(user: user, password: password))
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -361,7 +394,7 @@ class Registry {
|
|||||||
+ "while retrieving an authentication token", details: data.asText())
|
+ "while retrieving an authentication token", details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
await authenticationKeeper.set(try TokenResponse.parse(fromData: data))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func lookupCredentials() throws -> (String, String)? {
|
private func lookupCredentials() throws -> (String, String)? {
|
||||||
@@ -379,14 +412,18 @@ class Registry {
|
|||||||
return nil
|
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 -> (AsyncThrowingStream<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if doAuth {
|
||||||
let (name, value) = token.header()
|
if let (name, value) = await authenticationKeeper.header() {
|
||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.setValue("Tart/\(CI.version) (\(DeviceInfo.os); \(DeviceInfo.model))",
|
||||||
|
forHTTPHeaderField: "User-Agent")
|
||||||
|
|
||||||
return try await Fetcher.fetch(request, viaFile: viaFile)
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ class PIDLock {
|
|||||||
init(lockURL: URL) throws {
|
init(lockURL: URL) throws {
|
||||||
url = lockURL
|
url = lockURL
|
||||||
fd = open(lockURL.path, O_RDWR)
|
fd = open(lockURL.path, O_RDWR)
|
||||||
|
if fd == -1 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError.PIDLockMissing("failed to open lock file \(url): \(details)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
|
|||||||
@@ -1,97 +1,140 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct Darwin: Platform {
|
struct UnsupportedHostOSError: Error, CustomStringConvertible {
|
||||||
var ecid: VZMacMachineIdentifier
|
var description: String {
|
||||||
var hardwareModel: VZMacHardwareModel
|
"error: host macOS version is outdated to run this virtual machine"
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
|
||||||
self.ecid = ecid
|
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
#if arch(arm64)
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
|
|
||||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
struct Darwin: PlatformSuspendable {
|
||||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
var ecid: VZMacMachineIdentifier
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
var hardwareModel: VZMacHardwareModel
|
||||||
in: container,
|
|
||||||
debugDescription: "failed to initialize Data using the provided value")
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
|
self.ecid = ecid
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
}
|
}
|
||||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
init(from decoder: Decoder) throws {
|
||||||
in: container,
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
|
||||||
|
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 UnsupportedHostOSError()
|
||||||
|
}
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
}
|
}
|
||||||
self.ecid = ecid
|
|
||||||
|
|
||||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
func encode(to encoder: Encoder) throws {
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
}
|
}
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
}
|
}
|
||||||
self.hardwareModel = hardwareModel
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
if needsNestedVirtualization {
|
||||||
}
|
throw RuntimeError.VMConfigurationError("macOS virtual machines do not support nested virtualization")
|
||||||
|
}
|
||||||
|
|
||||||
func os() -> OS {
|
let result = VZMacPlatformConfiguration()
|
||||||
.darwin
|
|
||||||
}
|
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
result.machineIdentifier = ecid
|
||||||
VZMacOSBootLoader()
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(url: nvramURL)
|
||||||
}
|
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
if !hardwareModel.isSupported {
|
||||||
let result = VZMacPlatformConfiguration()
|
// 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.machineIdentifier = ecid
|
result.hardwareModel = hardwareModel
|
||||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
|
||||||
result.hardwareModel = hardwareModel
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
let result = VZMacGraphicsDeviceConfiguration()
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
if let hostMainScreen = NSScreen.main {
|
||||||
|
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
if let hostMainScreen = NSScreen.main {
|
|
||||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
|
||||||
result.displays = [
|
result.displays = [
|
||||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height,
|
||||||
|
// A reasonable guess according to Apple's documentation[1]
|
||||||
|
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
result.displays = [
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
VZMacGraphicsDisplayConfiguration(
|
if #available(macOS 14, *) {
|
||||||
widthInPixels: vmConfig.display.width,
|
// Mac keyboard is only supported by guests starting with macOS Ventura
|
||||||
heightInPixels: vmConfig.display.height,
|
return [VZUSBKeyboardConfiguration(), VZMacKeyboardConfiguration()]
|
||||||
// A reasonable guess according to Apple's documentation[1]
|
} else {
|
||||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
return [VZUSBKeyboardConfiguration()]
|
||||||
pixelsPerInch: 72
|
}
|
||||||
)
|
}
|
||||||
]
|
|
||||||
|
|
||||||
return result
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
|
||||||
}
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacKeyboardConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return keyboards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
if #available(macOS 13, *) {
|
// Trackpad is only supported by guests starting with macOS Ventura
|
||||||
// Trackpad is only supported starting with macOS Ventura
|
[VZUSBScreenCoordinatePointingDeviceConfiguration(), VZMacTrackpadConfiguration()]
|
||||||
// macOS Monterey will continue using a USB device == .darwin
|
}
|
||||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
|
||||||
} else {
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
|
||||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
if #available(macOS 14, *) {
|
||||||
|
return [VZMacTrackpadConfiguration()]
|
||||||
|
} else {
|
||||||
|
// fallback to the regular configuration
|
||||||
|
return pointingDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration {
|
||||||
VZGenericPlatformConfiguration()
|
let config = VZGenericPlatformConfiguration()
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
config.isNestedVirtualizationEnabled = needsNestedVirtualization
|
||||||
|
}
|
||||||
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
@@ -31,6 +35,10 @@ struct Linux: Platform {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration] {
|
||||||
|
[VZUSBKeyboardConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import Virtualization
|
|||||||
protocol Platform: Codable {
|
protocol Platform: Codable {
|
||||||
func os() -> OS
|
func os() -> OS
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
func platform(nvramURL: URL, needsNestedVirtualization: Bool) throws -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
|
func keyboards() -> [VZKeyboardConfiguration]
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protocol PlatformSuspendable: Platform {
|
||||||
|
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
|
||||||
|
func keyboardsSuspendable() -> [VZKeyboardConfiguration]
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ protocol Prunable {
|
|||||||
var url: URL { get }
|
var url: URL { get }
|
||||||
func delete() throws
|
func delete() throws
|
||||||
func accessDate() throws -> Date
|
func accessDate() throws -> Date
|
||||||
|
// size on disk as seen in Finder including empty blocks
|
||||||
func sizeBytes() throws -> Int
|
func sizeBytes() throws -> Int
|
||||||
|
// actual size on disk without empty blocks
|
||||||
|
func allocatedSizeBytes() throws -> Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Get.self,
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
|
Logout.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
@@ -25,6 +26,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Rename.self,
|
Rename.self,
|
||||||
Stop.self,
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
|
FQN.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
public static func main() async throws {
|
public static func main() async throws {
|
||||||
@@ -48,6 +50,10 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
|
||||||
|
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setExtra(value: ProcessInfo.processInfo.arguments, key: "Command-line arguments")
|
||||||
|
}
|
||||||
|
|
||||||
// Enrich future events with Cirrus CI-specific tags
|
// Enrich future events with Cirrus CI-specific tags
|
||||||
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
|
||||||
SentrySDK.configureScope { scope in
|
SentrySDK.configureScope { scope in
|
||||||
@@ -57,6 +63,11 @@ struct Root: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add commands that are only available on specific macOS versions
|
||||||
|
if #available(macOS 14, *) {
|
||||||
|
configuration.subcommands.append(Suspend.self)
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure the default SIGINT handled is disabled,
|
// Ensure the default SIGINT handled is disabled,
|
||||||
// otherwise there's a race between two handlers
|
// otherwise there's a race between two handlers
|
||||||
signal(SIGINT, SIG_IGN);
|
signal(SIGINT, SIG_IGN);
|
||||||
@@ -76,10 +87,12 @@ struct Root: AsyncParsableCommand {
|
|||||||
var command = try parseAsRoot()
|
var command = try parseAsRoot()
|
||||||
|
|
||||||
// Run garbage-collection before each command (shouldn't take too long)
|
// Run garbage-collection before each command (shouldn't take too long)
|
||||||
do {
|
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
|
||||||
try Config().gc()
|
do {
|
||||||
} catch {
|
try Config().gc()
|
||||||
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
} catch {
|
||||||
|
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if var asyncCommand = command as? AsyncParsableCommand {
|
if var asyncCommand = command as? AsyncParsableCommand {
|
||||||
@@ -94,7 +107,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
|
|
||||||
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
// Handle a non-ArgumentParser's exception that requires a specific exit code to be set
|
||||||
if let errorWithExitCode = error as? HasExitCode {
|
if let errorWithExitCode = error as? HasExitCode {
|
||||||
print(error)
|
fputs("\(error)\n", stderr)
|
||||||
|
|
||||||
Foundation.exit(errorWithExitCode.exitCode)
|
Foundation.exit(errorWithExitCode.exitCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Foundation
|
||||||
|
import System
|
||||||
|
|
||||||
extension URL {
|
extension URL {
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
@@ -13,7 +14,9 @@ extension URL {
|
|||||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||||
let ret = utimes(path, times)
|
let ret = utimes(path, times)
|
||||||
if ret != 0 {
|
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 Foundation
|
||||||
|
import XAttr
|
||||||
|
|
||||||
extension URL: Prunable {
|
extension URL: Prunable {
|
||||||
var url: URL {
|
var url: URL {
|
||||||
@@ -9,7 +10,35 @@ extension URL: Prunable {
|
|||||||
try FileManager.default.removeItem(at: self)
|
try FileManager.default.removeItem(at: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func allocatedSizeBytes() throws -> Int {
|
||||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,34 +5,26 @@ import Dynamic
|
|||||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||||
|
|
||||||
extension VZVirtualMachine {
|
extension VZVirtualMachine {
|
||||||
@available(macOS 12, *)
|
@MainActor @available(macOS 12, *)
|
||||||
func start(_ recovery: Bool) async throws {
|
func start(_ recovery: Bool) async throws {
|
||||||
if !recovery {
|
if !recovery {
|
||||||
// just use the regular API
|
// just use the regular API
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await self.start()
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.start(completionHandler: { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// use some private stuff only for recovery
|
// use some private stuff only for recovery
|
||||||
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||||
DispatchQueue.main.async {
|
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
||||||
let handler: @convention(block) (_ result: Any?) -> Void = { result in
|
if let error = result as? Error {
|
||||||
if let error = result as? Error {
|
continuation.resume(throwing: error)
|
||||||
continuation.resume(throwing: error)
|
} else {
|
||||||
} else {
|
continuation.resume(returning: ())
|
||||||
continuation.resume(returning: ())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// dynamic magic
|
|
||||||
let options = Dynamic._VZVirtualMachineStartOptions()
|
|
||||||
options.bootMacOSRecovery = recovery
|
|
||||||
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
|
||||||
}
|
}
|
||||||
|
// dynamic magic
|
||||||
|
let options = Dynamic._VZVirtualMachineStartOptions()
|
||||||
|
options.bootMacOSRecovery = recovery
|
||||||
|
Dynamic(self)._start(withOptions: options, completionHandler: handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import AsyncAlgorithms
|
import Semaphore
|
||||||
|
|
||||||
struct UnsupportedRestoreImageError: Error {
|
struct UnsupportedRestoreImageError: Error {
|
||||||
}
|
}
|
||||||
@@ -26,8 +26,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Virtualization.Framework's virtual machine
|
// Virtualization.Framework's virtual machine
|
||||||
@Published var virtualMachine: VZVirtualMachine
|
@Published var virtualMachine: VZVirtualMachine
|
||||||
|
|
||||||
|
// Virtualization.Framework's virtual machine configuration
|
||||||
|
var configuration: VZVirtualMachineConfiguration
|
||||||
|
|
||||||
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
// Semaphore used to communicate with the VZVirtualMachineDelegate
|
||||||
var sema = DispatchSemaphore(value: 0)
|
var sema = AsyncSemaphore(value: 0)
|
||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var name: String
|
var name: String
|
||||||
@@ -39,9 +42,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = []
|
serialPorts: [VZSerialPortConfiguration] = [],
|
||||||
|
suspendable: Bool = false,
|
||||||
|
nested: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true,
|
||||||
|
sync: VZDiskImageSynchronizationMode = .full,
|
||||||
|
caching: VZDiskImageCachingMode? = nil
|
||||||
) throws {
|
) throws {
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
@@ -52,11 +61,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
self.network = network
|
self.network = network
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
network: network, additionalStorageDevices: additionalStorageDevices,
|
||||||
directorySharingDevices: directorySharingDevices,
|
directorySharingDevices: directorySharingDevices,
|
||||||
serialPorts: serialPorts
|
serialPorts: serialPorts,
|
||||||
|
suspendable: suspendable,
|
||||||
|
nested: nested,
|
||||||
|
audio: audio,
|
||||||
|
clipboard: clipboard,
|
||||||
|
sync: sync,
|
||||||
|
caching: caching
|
||||||
)
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
@@ -66,9 +81,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
// Check if we already have this IPSW in cache
|
// Check if we already have this IPSW in cache
|
||||||
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
var headRequest = URLRequest(url: remoteURL)
|
||||||
|
headRequest.httpMethod = "HEAD"
|
||||||
|
let (_, headResponse) = try await Fetcher.fetch(headRequest, viaFile: false)
|
||||||
|
|
||||||
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
if let hash = headResponse.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
@@ -82,10 +99,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Download the IPSW
|
// Download the IPSW
|
||||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
let downloadProgress = Progress(totalUnitCount: 100)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(downloadProgress).log(defaultLogger)
|
||||||
|
|
||||||
|
let request = URLRequest(url: remoteURL)
|
||||||
|
let (channel, response) = try await Fetcher.fetch(request, viaFile: true, progress: downloadProgress)
|
||||||
|
|
||||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||||
|
defaultLogger.appendNewLine("Computing digest for \(temporaryLocation.path)...")
|
||||||
|
let digestProgress = Progress(totalUnitCount: response.expectedContentLength)
|
||||||
|
ProgressObserver(digestProgress).log(defaultLogger)
|
||||||
|
|
||||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||||
let lock = try FileLock(lockURL: temporaryLocation)
|
let lock = try FileLock(lockURL: temporaryLocation)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
@@ -97,7 +121,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
let chunkAsData = Data(chunk)
|
let chunkAsData = Data(chunk)
|
||||||
fileHandle.write(chunkAsData)
|
fileHandle.write(chunkAsData)
|
||||||
digest.update(chunkAsData)
|
digest.update(chunkAsData)
|
||||||
progress.completedUnitCount += Int64(chunk.count)
|
digestProgress.completedUnitCount += Int64(chunk.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
try fileHandle.close()
|
try fileHandle.close()
|
||||||
@@ -107,18 +131,6 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
return try FileManager.default.replaceItemAt(finalLocation, withItemAt: temporaryLocation)!
|
||||||
}
|
}
|
||||||
|
|
||||||
static func latestIPSWURL() async throws -> URL {
|
|
||||||
defaultLogger.appendNewLine("Looking up the latest supported IPSW...")
|
|
||||||
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.fetchLatestSupported() { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return image.url
|
|
||||||
}
|
|
||||||
|
|
||||||
var inFinalState: Bool {
|
var inFinalState: Bool {
|
||||||
get {
|
get {
|
||||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||||
@@ -128,82 +140,90 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
#if arch(arm64)
|
||||||
vmDir: VMDirectory,
|
init(
|
||||||
ipswURL: URL,
|
vmDir: VMDirectory,
|
||||||
diskSizeGB: UInt16,
|
ipswURL: URL,
|
||||||
network: Network = NetworkShared(),
|
diskSizeGB: UInt16,
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
network: Network = NetworkShared(),
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
serialPorts: [VZSerialPortConfiguration] = []
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
) async throws {
|
serialPorts: [VZSerialPortConfiguration] = []
|
||||||
var ipswURL = ipswURL
|
) async throws {
|
||||||
|
var ipswURL = ipswURL
|
||||||
|
|
||||||
if !ipswURL.isFileURL {
|
if !ipswURL.isFileURL {
|
||||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||||
}
|
|
||||||
|
|
||||||
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
|
||||||
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
|
||||||
// cannot deal with paths that contain symlinks, so expand them here first.
|
|
||||||
ipswURL.resolveSymlinksInPath()
|
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
|
||||||
// that match both the image and our platform
|
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
|
||||||
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
|
||||||
continuation.resume(with: result)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||||
throw UnsupportedRestoreImageError()
|
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||||
}
|
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||||
|
ipswURL.resolveSymlinksInPath()
|
||||||
|
|
||||||
// Create NVRAM
|
// Load the restore image and try to get the requirements
|
||||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
// that match both the image and our platform
|
||||||
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
// Create disk
|
VZMacOSRestoreImage.load(from: ipswURL) { result in
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
|
||||||
|
|
||||||
name = vmDir.name
|
|
||||||
// Create config
|
|
||||||
config = VMConfig(
|
|
||||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
|
||||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
|
||||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
|
||||||
)
|
|
||||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
|
||||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
|
||||||
try config.save(toURL: vmDir.configURL)
|
|
||||||
|
|
||||||
// Initialize the virtual machine and its configuration
|
|
||||||
self.network = network
|
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
|
||||||
vmConfig: config, network: network,
|
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
|
||||||
directorySharingDevices: directorySharingDevices,
|
|
||||||
serialPorts: serialPorts
|
|
||||||
)
|
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
|
||||||
|
|
||||||
super.init()
|
|
||||||
virtualMachine.delegate = self
|
|
||||||
|
|
||||||
// Run automated installation
|
|
||||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
||||||
DispatchQueue.main.async { [ipswURL] in
|
|
||||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
|
||||||
|
|
||||||
defaultLogger.appendNewLine("Installing OS...")
|
|
||||||
ProgressObserver(installer.progress).log(defaultLogger)
|
|
||||||
|
|
||||||
installer.install { result in
|
|
||||||
continuation.resume(with: result)
|
continuation.resume(with: result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let requirements = image.mostFeaturefulSupportedConfiguration else {
|
||||||
|
throw UnsupportedRestoreImageError()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create NVRAM
|
||||||
|
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||||
|
|
||||||
|
// Create disk
|
||||||
|
try vmDir.resizeDisk(diskSizeGB)
|
||||||
|
|
||||||
|
name = vmDir.name
|
||||||
|
// Create config
|
||||||
|
config = VMConfig(
|
||||||
|
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||||
|
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||||
|
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||||
|
)
|
||||||
|
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||||
|
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||||
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
|
// Initialize the virtual machine and its configuration
|
||||||
|
self.network = network
|
||||||
|
configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
|
vmConfig: config, network: network,
|
||||||
|
additionalStorageDevices: additionalStorageDevices,
|
||||||
|
directorySharingDevices: directorySharingDevices,
|
||||||
|
serialPorts: serialPorts
|
||||||
|
)
|
||||||
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
virtualMachine.delegate = self
|
||||||
|
|
||||||
|
// Run automated installation
|
||||||
|
try await install(ipswURL)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@MainActor
|
||||||
|
private func install(_ url: URL) async throws {
|
||||||
|
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: url)
|
||||||
|
defaultLogger.appendNewLine("Installing OS...")
|
||||||
|
ProgressObserver(installer.progress).log(defaultLogger)
|
||||||
|
|
||||||
|
try await withTaskCancellationHandler(operation: {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
installer.install { result in
|
||||||
|
continuation.resume(with: result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, onCancel: {
|
||||||
|
installer.progress.cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||||
@@ -220,53 +240,69 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
return try VM(vmDir: vmDir)
|
return try VM(vmDir: vmDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ recovery: Bool) async throws {
|
func start(recovery: Bool, resume shouldResume: Bool) async throws {
|
||||||
try network.run(sema)
|
try network.run(sema)
|
||||||
|
|
||||||
let startTask = DispatchQueue.main.sync {
|
if shouldResume {
|
||||||
Task {
|
try await resume()
|
||||||
if #available(macOS 13, *) {
|
} else {
|
||||||
// new API introduced in Ventura
|
try await start(recovery)
|
||||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
}
|
||||||
startOptions.startUpFromMacOSRecovery = recovery
|
}
|
||||||
try await virtualMachine.start(options: startOptions)
|
|
||||||
} else {
|
func run() async throws {
|
||||||
// use method that also available on Monterey
|
do {
|
||||||
try await virtualMachine.start(recovery)
|
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.
|
||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
|
||||||
// Await on VZVirtualMachine.start() result
|
|
||||||
_ = try await startTask.value
|
|
||||||
|
|
||||||
// Wait for the VM to finish running
|
|
||||||
// or for the exit condition
|
|
||||||
sema.wait()
|
|
||||||
}, onCancel: {
|
|
||||||
sema.signal()
|
|
||||||
})
|
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
DispatchQueue.main.sync {
|
if (self.virtualMachine.state == VZVirtualMachine.State.running) {
|
||||||
Task {
|
print("Stopping VM...")
|
||||||
try await self.virtualMachine.stop()
|
try await stop()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try await network.stop()
|
try await network.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func start(_ recovery: Bool) async throws {
|
||||||
|
#if arch(arm64)
|
||||||
|
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||||
|
startOptions.startUpFromMacOSRecovery = recovery
|
||||||
|
try await virtualMachine.start(options: startOptions)
|
||||||
|
#else
|
||||||
|
try await virtualMachine.start()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func resume() async throws {
|
||||||
|
try await virtualMachine.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func stop() async throws {
|
||||||
|
try await self.virtualMachine.stop()
|
||||||
|
}
|
||||||
|
|
||||||
static func craftConfiguration(
|
static func craftConfiguration(
|
||||||
diskURL: URL,
|
diskURL: URL,
|
||||||
nvramURL: URL,
|
nvramURL: URL,
|
||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalStorageDevices: [VZStorageDeviceConfiguration],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration],
|
||||||
serialPorts: [VZSerialPortConfiguration]
|
serialPorts: [VZSerialPortConfiguration],
|
||||||
|
suspendable: Bool = false,
|
||||||
|
nested: Bool = false,
|
||||||
|
audio: Bool = true,
|
||||||
|
clipboard: Bool = true,
|
||||||
|
sync: VZDiskImageSynchronizationMode = .full,
|
||||||
|
caching: VZDiskImageCachingMode? = nil
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -278,37 +314,75 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
configuration.memorySize = vmConfig.memorySize
|
configuration.memorySize = vmConfig.memorySize
|
||||||
|
|
||||||
// Platform
|
// Platform
|
||||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
configuration.platform = try vmConfig.platform.platform(nvramURL: nvramURL, needsNestedVirtualization: nested)
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||||
|
|
||||||
// Audio
|
// Audio
|
||||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
|
||||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
if audio && !suspendable {
|
||||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
|
||||||
|
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||||
|
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||||
|
|
||||||
|
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||||
|
} else {
|
||||||
|
// just a null speaker
|
||||||
|
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
configuration.audioDevices = [soundDeviceConfiguration]
|
configuration.audioDevices = [soundDeviceConfiguration]
|
||||||
|
|
||||||
// Keyboard and mouse
|
// Keyboard and mouse
|
||||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
if suspendable, let platformSuspendable = vmConfig.platform.self as? PlatformSuspendable {
|
||||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
configuration.keyboards = platformSuspendable.keyboardsSuspendable()
|
||||||
|
configuration.pointingDevices = platformSuspendable.pointingDevicesSuspendable()
|
||||||
|
} else {
|
||||||
|
configuration.keyboards = vmConfig.platform.keyboards()
|
||||||
|
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||||
|
}
|
||||||
|
|
||||||
// Networking
|
// Networking
|
||||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
configuration.networkDevices = network.attachments().map {
|
||||||
vio.attachment = network.attachment()
|
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||||
vio.macAddress = vmConfig.macAddress
|
vio.attachment = $0
|
||||||
configuration.networkDevices = [vio]
|
vio.macAddress = vmConfig.macAddress
|
||||||
|
return vio
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clipboard sharing via Spice agent
|
||||||
|
if clipboard && vmConfig.os == .linux {
|
||||||
|
let spiceAgentConsoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||||
|
let spiceAgentPort = VZVirtioConsolePortConfiguration()
|
||||||
|
spiceAgentPort.name = VZSpiceAgentPortAttachment.spiceAgentPortName
|
||||||
|
spiceAgentPort.attachment = VZSpiceAgentPortAttachment()
|
||||||
|
spiceAgentConsoleDevice.ports[0] = spiceAgentPort
|
||||||
|
configuration.consoleDevices.append(spiceAgentConsoleDevice)
|
||||||
|
}
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
let attachment: VZDiskImageStorageDeviceAttachment = try VZDiskImageStorageDeviceAttachment(
|
||||||
attachments.append(contentsOf: additionalDiskAttachments)
|
url: diskURL,
|
||||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
readOnly: false,
|
||||||
|
// When not specified, use "cached" caching mode for Linux VMs to prevent file-system corruption[1]
|
||||||
|
//
|
||||||
|
// [1]: https://github.com/cirruslabs/tart/pull/675
|
||||||
|
cachingMode: caching ?? (vmConfig.os == .linux ? .cached : .automatic),
|
||||||
|
synchronizationMode: sync
|
||||||
|
)
|
||||||
|
|
||||||
|
var devices: [VZStorageDeviceConfiguration] = [VZVirtioBlockDeviceConfiguration(attachment: attachment)]
|
||||||
|
devices.append(contentsOf: additionalStorageDevices)
|
||||||
|
configuration.storageDevices = devices
|
||||||
|
|
||||||
// Entropy
|
// Entropy
|
||||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
if !suspendable {
|
||||||
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
|
||||||
// Directory sharing devices
|
// Directory sharing devices
|
||||||
configuration.directorySharingDevices = directorySharingDevices
|
configuration.directorySharingDevices = directorySharingDevices
|
||||||
@@ -316,6 +390,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Serial Port
|
// Serial Port
|
||||||
configuration.serialPorts = serialPorts
|
configuration.serialPorts = serialPorts
|
||||||
|
|
||||||
|
// Version console device
|
||||||
|
//
|
||||||
|
// A dummy console device useful for implementing
|
||||||
|
// host feature checks in the guest agent software.
|
||||||
|
if !suspendable {
|
||||||
|
let consolePort = VZVirtioConsolePortConfiguration()
|
||||||
|
consolePort.name = "tart-version-\(CI.version)"
|
||||||
|
|
||||||
|
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
|
||||||
|
consoleDevice.ports[0] = consolePort
|
||||||
|
|
||||||
|
configuration.consoleDevices.append(consoleDevice)
|
||||||
|
}
|
||||||
|
|
||||||
try configuration.validate()
|
try configuration.validate()
|
||||||
|
|
||||||
return configuration
|
return configuration
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ enum CodingKeys: String, CodingKey {
|
|||||||
case memorySize
|
case memorySize
|
||||||
case macAddress
|
case macAddress
|
||||||
case display
|
case display
|
||||||
|
case displayRefit
|
||||||
|
|
||||||
// macOS-specific keys
|
// macOS-specific keys
|
||||||
case ecid
|
case ecid
|
||||||
@@ -52,6 +53,7 @@ struct VMConfig: Codable {
|
|||||||
private(set) var memorySize: UInt64
|
private(set) var memorySize: UInt64
|
||||||
var macAddress: VZMACAddress
|
var macAddress: VZMACAddress
|
||||||
var display: VMDisplayConfig = VMDisplayConfig()
|
var display: VMDisplayConfig = VMDisplayConfig()
|
||||||
|
var displayRefit: Bool?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
@@ -95,13 +97,16 @@ struct VMConfig: Codable {
|
|||||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||||
switch os {
|
switch os {
|
||||||
case .darwin:
|
case .darwin:
|
||||||
platform = try Darwin(from: decoder)
|
#if arch(arm64)
|
||||||
|
platform = try Darwin(from: decoder)
|
||||||
|
#else
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
forKey: .os,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "Darwin VMs are only supported on Apple Silicon hosts")
|
||||||
|
#endif
|
||||||
case .linux:
|
case .linux:
|
||||||
if #available(macOS 13, *) {
|
platform = try Linux(from: decoder)
|
||||||
platform = try Linux(from: decoder)
|
|
||||||
} else {
|
|
||||||
throw UnsupportedOSError("Linux VMs", "are")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||||
@@ -118,6 +123,7 @@ struct VMConfig: Codable {
|
|||||||
self.macAddress = macAddress
|
self.macAddress = macAddress
|
||||||
|
|
||||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||||
|
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -133,23 +139,36 @@ struct VMConfig: Codable {
|
|||||||
try container.encode(memorySize, forKey: .memorySize)
|
try container.encode(memorySize, forKey: .memorySize)
|
||||||
try container.encode(macAddress.string, forKey: .macAddress)
|
try container.encode(macAddress.string, forKey: .macAddress)
|
||||||
try container.encode(display, forKey: .display)
|
try container.encode(display, forKey: .display)
|
||||||
|
if let displayRefit = displayRefit {
|
||||||
|
try container.encode(displayRefit, forKey: .displayRefit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if os == .darwin && cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cpuCount < VZVirtualMachineConfiguration.minimumAllowedCPUCount {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedCPUCount) CPU cores"
|
||||||
|
+ " at minimum (requested \(cpuCount))")
|
||||||
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if os == .darwin && memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySize))")
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if memorySize < VZVirtualMachineConfiguration.minimumAllowedMemorySize {
|
||||||
|
throw LessThanMinimalResourcesError("VM should have \(VZVirtualMachineConfiguration.minimumAllowedMemorySize) bytes"
|
||||||
|
+ " of memory at minimum (requested \(memorySize))")
|
||||||
|
}
|
||||||
|
|
||||||
self.memorySize = memorySize
|
self.memorySize = memorySize
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ extension VMDirectory {
|
|||||||
try? decodeStream.close()
|
try? decodeStream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else {
|
guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path),
|
||||||
|
flags: [.ignoreOperationNotPermitted]) else {
|
||||||
let details = Errno(rawValue: CInt(errno))
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import Foundation
|
|
||||||
import Compression
|
import Compression
|
||||||
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
|
||||||
enum OCIError: Error {
|
enum OCIError: Error {
|
||||||
case ShouldBeExactlyOneLayer
|
case ShouldBeExactlyOneLayer
|
||||||
case ShouldBeAtLeastOneLayer
|
case ShouldBeAtLeastOneLayer
|
||||||
case FailedToCreateVmFile
|
case FailedToCreateVmFile
|
||||||
|
case LayerIsMissingUncompressedSizeAnnotation
|
||||||
|
case LayerIsMissingUncompressedDigestAnnotation
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VMDirectory {
|
extension VMDirectory {
|
||||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
func pullFromRegistry(registry: Registry, manifest: OCIManifest, concurrency: UInt, localLayerCache: LocalLayerCache?, deduplicate: Bool) async throws {
|
||||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
|
||||||
|
|
||||||
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
|
||||||
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
|
||||||
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, reference: String) async throws {
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
|
||||||
|
|
||||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
|
||||||
|
|
||||||
return try await pullFromRegistry(registry: registry, manifest: manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
|
|
||||||
// Pull VM's config file layer and re-serialize it into a config file
|
// Pull VM's config file layer and re-serialize it into a config file
|
||||||
let configLayers = manifest.layers.filter {
|
let configLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.configMediaType
|
$0.mediaType == configMediaType
|
||||||
}
|
}
|
||||||
if configLayers.count != 1 {
|
if configLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -41,50 +28,48 @@ extension VMDirectory {
|
|||||||
}
|
}
|
||||||
try configFile.close()
|
try configFile.close()
|
||||||
|
|
||||||
// Pull VM's disk layers and decompress them sequentially into a disk file
|
// Pull VM's disk layers and decompress them into a disk file
|
||||||
let diskLayers = manifest.layers.filter {
|
let diskImplType: Disk.Type
|
||||||
$0.mediaType == Self.diskMediaType
|
let layers: [OCIManifestLayer]
|
||||||
}
|
|
||||||
if diskLayers.isEmpty {
|
if manifest.layers.contains(where: { $0.mediaType == diskV1MediaType }) {
|
||||||
|
diskImplType = DiskV1.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV1MediaType }
|
||||||
|
} else if manifest.layers.contains(where: { $0.mediaType == diskV2MediaType }) {
|
||||||
|
diskImplType = DiskV2.self
|
||||||
|
layers = manifest.layers.filter { $0.mediaType == diskV2MediaType }
|
||||||
|
} else {
|
||||||
throw OCIError.ShouldBeAtLeastOneLayer
|
throw OCIError.ShouldBeAtLeastOneLayer
|
||||||
}
|
}
|
||||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
|
||||||
throw OCIError.FailedToCreateVmFile
|
|
||||||
}
|
|
||||||
let disk = try FileHandle(forWritingTo: diskURL)
|
|
||||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
|
||||||
if let data = data {
|
|
||||||
disk.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress
|
let diskCompressedSize = layers.map { Int64($0.size) }.reduce(0, +)
|
||||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte)
|
||||||
$0.size
|
|
||||||
}
|
|
||||||
.reduce(0) {
|
|
||||||
$0 + $1
|
|
||||||
})
|
|
||||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||||
|
|
||||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
for diskLayer in diskLayers {
|
do {
|
||||||
try await registry.pullBlob(diskLayer.digest) { data in
|
try await diskImplType.pull(registry: registry, diskLayers: layers, diskURL: diskURL,
|
||||||
try filter.write(data)
|
concurrency: concurrency, progress: progress,
|
||||||
progress.completedUnitCount += Int64(data.count)
|
localLayerCache: localLayerCache,
|
||||||
}
|
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)
|
||||||
}
|
}
|
||||||
try filter.finalize()
|
|
||||||
try disk.close()
|
|
||||||
SentrySDK.span?.setMeasurement(name: "compressed_disk_size", value: diskCompressedSize as NSNumber, unit: MeasurementUnitInformation.byte);
|
|
||||||
|
|
||||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||||
|
|
||||||
let nvramLayers = manifest.layers.filter {
|
let nvramLayers = manifest.layers.filter {
|
||||||
$0.mediaType == Self.nvramMediaType
|
$0.mediaType == nvramMediaType
|
||||||
}
|
}
|
||||||
if nvramLayers.count != 1 {
|
if nvramLayers.count != 1 {
|
||||||
throw OCIError.ShouldBeExactlyOneLayer
|
throw OCIError.ShouldBeExactlyOneLayer
|
||||||
@@ -97,9 +82,12 @@ extension VMDirectory {
|
|||||||
nvram.write(data)
|
nvram.write(data)
|
||||||
}
|
}
|
||||||
try nvram.close()
|
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) async throws -> RemoteName {
|
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int, diskFormat: String, concurrency: UInt) async throws -> RemoteName {
|
||||||
var layers = Array<OCIManifestLayer>()
|
var layers = Array<OCIManifestLayer>()
|
||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
@@ -107,32 +95,22 @@ extension VMDirectory {
|
|||||||
let configJSON = try JSONEncoder().encode(config)
|
let configJSON = try JSONEncoder().encode(config)
|
||||||
defaultLogger.appendNewLine("pushing config...")
|
defaultLogger.appendNewLine("pushing config...")
|
||||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
layers.append(OCIManifestLayer(mediaType: configMediaType, size: configJSON.count, digest: configDigest))
|
||||||
|
|
||||||
// Progress
|
// Compress the disk file as multiple chunks and push them as disk layers
|
||||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||||
let progress = Progress(totalUnitCount: diskSize)
|
let progress = Progress(totalUnitCount: diskSize)
|
||||||
ProgressObserver(progress).log(defaultLogger)
|
ProgressObserver(progress).log(defaultLogger)
|
||||||
|
|
||||||
// Read VM's compressed disk as chunks
|
switch diskFormat {
|
||||||
// and sequentially upload them as blobs
|
case "v1":
|
||||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
layers.append(contentsOf: try await DiskV1.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||||
let mappedDiskSize = mappedDisk.count
|
case "v2":
|
||||||
var mappedDiskReadOffset = 0
|
layers.append(contentsOf: try await DiskV2.push(diskURL: diskURL, registry: registry, chunkSizeMb: chunkSizeMb, concurrency: concurrency, progress: progress))
|
||||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
default:
|
||||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
throw RuntimeError.OCIUnsupportedDiskFormat(diskFormat)
|
||||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
|
||||||
mappedDiskReadOffset += bytesRead
|
|
||||||
|
|
||||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
|
||||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read VM's NVRAM and push it as blob
|
// Read VM's NVRAM and push it as blob
|
||||||
@@ -140,7 +118,7 @@ extension VMDirectory {
|
|||||||
|
|
||||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
layers.append(OCIManifestLayer(mediaType: nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||||
|
|
||||||
// Craft a stub OCI config for Docker Hub compatibility
|
// Craft a stub OCI config for Docker Hub compatibility
|
||||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||||
@@ -148,7 +126,8 @@ extension VMDirectory {
|
|||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
layers: layers,
|
layers: layers,
|
||||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
uncompressedDiskSize: UInt64(diskSize),
|
||||||
|
uploadDate: Date()
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
@@ -159,7 +138,7 @@ extension VMDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pushedReference = Reference(digest: try manifest.digest())
|
let pushedReference = Reference(digest: try manifest.digest())
|
||||||
return RemoteName(host: registry.baseURL.host!, namespace: registry.namespace, reference: pushedReference)
|
return RemoteName(host: registry.host!, namespace: registry.namespace, reference: pushedReference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
|
enum State: String {
|
||||||
|
case Running = "running"
|
||||||
|
case Suspended = "suspended"
|
||||||
|
case Stopped = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
var configURL: URL {
|
var configURL: URL {
|
||||||
@@ -13,6 +20,12 @@ struct VMDirectory: Prunable {
|
|||||||
var nvramURL: URL {
|
var nvramURL: URL {
|
||||||
baseURL.appendingPathComponent("nvram.bin")
|
baseURL.appendingPathComponent("nvram.bin")
|
||||||
}
|
}
|
||||||
|
var stateURL: URL {
|
||||||
|
baseURL.appendingPathComponent("state.vzvmsave")
|
||||||
|
}
|
||||||
|
var manifestURL: URL {
|
||||||
|
baseURL.appendingPathComponent("manifest.json")
|
||||||
|
}
|
||||||
|
|
||||||
var explicitlyPulledMark: URL {
|
var explicitlyPulledMark: URL {
|
||||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||||
@@ -26,8 +39,31 @@ struct VMDirectory: Prunable {
|
|||||||
baseURL
|
baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lock() throws -> PIDLock {
|
||||||
|
try PIDLock(lockURL: configURL)
|
||||||
|
}
|
||||||
|
|
||||||
func running() throws -> Bool {
|
func running() throws -> Bool {
|
||||||
try PIDLock(lockURL: configURL).pid() != 0
|
// The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
|
||||||
|
// which is fine to report as "not running".
|
||||||
|
//
|
||||||
|
// The other reasons are unlikely and the cost of getting a false positive is way less than
|
||||||
|
// the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
|
||||||
|
guard let lock = try? lock() else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try lock.pid() != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func state() throws -> State {
|
||||||
|
if try running() {
|
||||||
|
return State.Running
|
||||||
|
} else if FileManager.default.fileExists(atPath: stateURL.path) {
|
||||||
|
return State.Suspended
|
||||||
|
} else {
|
||||||
|
return State.Stopped
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func temporary() throws -> VMDirectory {
|
static func temporary() throws -> VMDirectory {
|
||||||
@@ -37,6 +73,17 @@ struct VMDirectory: Prunable {
|
|||||||
return VMDirectory(baseURL: tmpDir)
|
return VMDirectory(baseURL: tmpDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//Create tmp directory with hashing
|
||||||
|
static func temporaryDeterministic(key: String) throws -> VMDirectory {
|
||||||
|
let keyData = Data(key.utf8)
|
||||||
|
let hash = Insecure.MD5.hash(data: keyData)
|
||||||
|
// Convert hash to string
|
||||||
|
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
|
||||||
|
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
|
||||||
|
return VMDirectory(baseURL: tmpDir)
|
||||||
|
}
|
||||||
|
|
||||||
var initialized: Bool {
|
var initialized: Bool {
|
||||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||||
@@ -55,9 +102,9 @@ struct VMDirectory: Prunable {
|
|||||||
try? FileManager.default.removeItem(at: nvramURL)
|
try? FileManager.default.removeItem(at: nvramURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate(userFriendlyName: String) throws {
|
||||||
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||||
throw RuntimeError.VMDoesNotExist(name: baseURL.lastPathComponent)
|
throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
@@ -70,6 +117,7 @@ struct VMDirectory: Prunable {
|
|||||||
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
||||||
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
||||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||||
|
try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
|
||||||
|
|
||||||
// Re-generate MAC address
|
// Re-generate MAC address
|
||||||
if generateMAC {
|
if generateMAC {
|
||||||
@@ -85,6 +133,8 @@ struct VMDirectory: Prunable {
|
|||||||
var vmConfig = try VMConfig(fromURL: configURL)
|
var vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
|
// cleanup state if any
|
||||||
|
try? FileManager.default.removeItem(at: stateURL)
|
||||||
|
|
||||||
try vmConfig.save(toURL: configURL)
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
@@ -93,20 +143,53 @@ struct VMDirectory: Prunable {
|
|||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
// macOS considers kilo being 1000 and not 1024
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
if desiredDiskFileLength < currentDiskFileLength {
|
||||||
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||||
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||||
|
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
||||||
|
"than the current disk size of \(currentLengthHuman)")
|
||||||
|
} else if desiredDiskFileLength > currentDiskFileLength {
|
||||||
|
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||||
|
}
|
||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
|
let lock = try lock()
|
||||||
|
|
||||||
|
if try !lock.trylock() {
|
||||||
|
throw RuntimeError.VMIsRunning(name)
|
||||||
|
}
|
||||||
|
|
||||||
try FileManager.default.removeItem(at: baseURL)
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
|
||||||
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func accessDate() throws -> Date {
|
func accessDate() throws -> Date {
|
||||||
try baseURL.accessDate()
|
try baseURL.accessDate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func allocatedSizeBytes() throws -> Int {
|
||||||
|
try configURL.allocatedSizeBytes() + diskURL.allocatedSizeBytes() + nvramURL.allocatedSizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allocatedSizeGB() throws -> Int {
|
||||||
|
try allocatedSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicatedSizeBytes() throws -> Int {
|
||||||
|
try configURL.deduplicatedSizeBytes() + diskURL.deduplicatedSizeBytes() + nvramURL.deduplicatedSizeBytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduplicatedSizeGB() throws -> Int {
|
||||||
|
try deduplicatedSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func sizeBytes() throws -> Int {
|
func sizeBytes() throws -> Int {
|
||||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ class VMStorageHelper {
|
|||||||
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
||||||
do {
|
do {
|
||||||
return try closure()
|
return try closure()
|
||||||
|
} catch RuntimeError.PIDLockMissing {
|
||||||
|
throw RuntimeError.VMDoesNotExist(name: name)
|
||||||
} catch {
|
} catch {
|
||||||
if error.isFileNotFound() {
|
if error.isFileNotFound() {
|
||||||
throw RuntimeError.VMDoesNotExist(name: name)
|
throw RuntimeError.VMDoesNotExist(name: name)
|
||||||
@@ -34,9 +36,15 @@ class VMStorageHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NSError {
|
||||||
|
func isFileNotFound() -> Bool {
|
||||||
|
return self.code == NSFileNoSuchFileError || self.code == NSFileReadNoSuchFileError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Error {
|
extension Error {
|
||||||
func isFileNotFound() -> Bool {
|
func isFileNotFound() -> Bool {
|
||||||
(self as NSError).code == NSFileReadNoSuchFileError
|
(self as NSError).isFileNotFound() || (self as NSError).underlyingErrors.contains(where: { $0.isFileNotFound() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,19 +52,29 @@ enum RuntimeError : Error {
|
|||||||
case VMConfigurationError(_ message: String)
|
case VMConfigurationError(_ message: String)
|
||||||
case VMDoesNotExist(name: String)
|
case VMDoesNotExist(name: String)
|
||||||
case VMMissingFiles(_ message: String)
|
case VMMissingFiles(_ message: String)
|
||||||
case VMNotRunning(_ message: String)
|
case VMIsRunning(_ name: String)
|
||||||
|
case VMNotRunning(_ name: String)
|
||||||
case VMAlreadyRunning(_ message: String)
|
case VMAlreadyRunning(_ message: String)
|
||||||
case NoIPAddressFound(_ message: String)
|
case NoIPAddressFound(_ message: String)
|
||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
|
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||||
|
case InvalidDiskSize(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
|
case PIDLockMissing(_ message: String)
|
||||||
case FailedToParseRemoteName(_ message: String)
|
case FailedToParseRemoteName(_ message: String)
|
||||||
case VMTerminationFailed(_ message: String)
|
case VMTerminationFailed(_ message: String)
|
||||||
|
case ImproperlyFormattedHost(_ host: String, _ hint: String)
|
||||||
case InvalidCredentials(_ message: String)
|
case InvalidCredentials(_ message: String)
|
||||||
case VMDirectoryAlreadyInitialized(_ message: String)
|
case VMDirectoryAlreadyInitialized(_ message: String)
|
||||||
case ExportFailed(_ message: String)
|
case ExportFailed(_ message: String)
|
||||||
case ImportFailed(_ message: String)
|
case ImportFailed(_ message: String)
|
||||||
case SoftnetFailed(_ message: String)
|
case SoftnetFailed(_ message: String)
|
||||||
|
case OCIStorageError(_ message: String)
|
||||||
|
case OCIUnsupportedDiskFormat(_ format: String)
|
||||||
|
case SuspendFailed(_ message: String)
|
||||||
|
case PullFailed(_ message: String)
|
||||||
|
case VirtualMachineLimitExceeded(_ hint: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol HasExitCode {
|
protocol HasExitCode {
|
||||||
@@ -72,22 +90,32 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "the specified VM \"\(name)\" does not exist"
|
return "the specified VM \"\(name)\" does not exist"
|
||||||
case .VMMissingFiles(let message):
|
case .VMMissingFiles(let message):
|
||||||
return message
|
return message
|
||||||
case .VMNotRunning(let message):
|
case .VMIsRunning(let name):
|
||||||
return message
|
return "VM \"\(name)\" is running"
|
||||||
|
case .VMNotRunning(let name):
|
||||||
|
return "VM \"\(name)\" is not running"
|
||||||
case .VMAlreadyRunning(let message):
|
case .VMAlreadyRunning(let message):
|
||||||
return message
|
return message
|
||||||
case .NoIPAddressFound(let message):
|
case .NoIPAddressFound(let message):
|
||||||
return message
|
return message
|
||||||
case .DiskAlreadyInUse(let message):
|
case .DiskAlreadyInUse(let message):
|
||||||
return message
|
return message
|
||||||
|
case .FailedToOpenBlockDevice(let path, let explanation):
|
||||||
|
return "failed to open block device \(path): \(explanation)"
|
||||||
|
case .InvalidDiskSize(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
return message
|
return message
|
||||||
|
case .PIDLockMissing(let message):
|
||||||
|
return message
|
||||||
case .FailedToParseRemoteName(let cause):
|
case .FailedToParseRemoteName(let cause):
|
||||||
return "failed to parse remote name: \(cause)"
|
return "failed to parse remote name: \(cause)"
|
||||||
case .VMTerminationFailed(let message):
|
case .VMTerminationFailed(let message):
|
||||||
return message
|
return message
|
||||||
|
case .ImproperlyFormattedHost(let host, let hint):
|
||||||
|
return "improperly formatted host \"\(host)\" was provided\(hint)"
|
||||||
case .InvalidCredentials(let message):
|
case .InvalidCredentials(let message):
|
||||||
return message
|
return message
|
||||||
case .VMDirectoryAlreadyInitialized(let message):
|
case .VMDirectoryAlreadyInitialized(let message):
|
||||||
@@ -98,6 +126,16 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "VM import failed: \(message)"
|
return "VM import failed: \(message)"
|
||||||
case .SoftnetFailed(let message):
|
case .SoftnetFailed(let message):
|
||||||
return "Softnet failed: \(message)"
|
return "Softnet failed: \(message)"
|
||||||
|
case .OCIStorageError(let message):
|
||||||
|
return "OCI storage error: \(message)"
|
||||||
|
case .OCIUnsupportedDiskFormat(let format):
|
||||||
|
return "OCI disk format \(format) is not supported by this version of Tart"
|
||||||
|
case .SuspendFailed(let message):
|
||||||
|
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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,6 +143,8 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
extension RuntimeError : HasExitCode {
|
extension RuntimeError : HasExitCode {
|
||||||
var exitCode: Int32 {
|
var exitCode: Int32 {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .VMDoesNotExist:
|
||||||
|
return 2
|
||||||
case .VMNotRunning:
|
case .VMNotRunning:
|
||||||
return 2
|
return 2
|
||||||
case .VMAlreadyRunning:
|
case .VMAlreadyRunning:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageLocal {
|
class VMStorageLocal: PrunableStorage {
|
||||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||||
|
|
||||||
private func vmURL(_ name: String) -> URL {
|
private func vmURL(_ name: String) -> URL {
|
||||||
@@ -14,7 +14,9 @@ class VMStorageLocal {
|
|||||||
func open(_ name: String) throws -> VMDirectory {
|
func open(_ name: String) throws -> VMDirectory {
|
||||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate(userFriendlyName: name)
|
||||||
|
|
||||||
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
return vmDir
|
return vmDir
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,7 @@ class VMStorageLocal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ name: String) throws {
|
func delete(_ name: String) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try VMDirectory(baseURL: vmURL(name)).delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func list() throws -> [(String, VMDirectory)] {
|
||||||
@@ -63,6 +65,10 @@ class VMStorageLocal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prunables() throws -> [Prunable] {
|
||||||
|
try list().map { (_, vmDir) in vmDir }.filter { try !$0.running() }
|
||||||
|
}
|
||||||
|
|
||||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||||
try list().contains { try $1.macAddress() == macAddress }
|
try list().contains { try $1.macAddress() == macAddress }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Sentry
|
import Sentry
|
||||||
|
import Retry
|
||||||
|
|
||||||
class VMStorageOCI: PrunableStorage {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
@@ -16,10 +17,20 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
VMDirectory(baseURL: vmURL(name)).initialized
|
VMDirectory(baseURL: vmURL(name)).initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func digest(_ name: RemoteName) throws -> String {
|
||||||
|
let digest = vmURL(name).resolvingSymlinksInPath().lastPathComponent
|
||||||
|
|
||||||
|
if !digest.starts(with: "sha256:") {
|
||||||
|
throw RuntimeError.OCIStorageError("\(name) is not a digest and doesn't point to a digest")
|
||||||
|
}
|
||||||
|
|
||||||
|
return digest
|
||||||
|
}
|
||||||
|
|
||||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate(userFriendlyName: name.description)
|
||||||
|
|
||||||
try vmDir.baseURL.updateAccessDate()
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
@@ -102,6 +113,10 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split the relative VM's path at the last component
|
||||||
|
// and figure out which character should be used
|
||||||
|
// to join them together, either ":" for tags or
|
||||||
|
// "@" for hashes
|
||||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@@ -112,6 +127,9 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
name = parts.joined(separator: "@")
|
name = parts.joined(separator: "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the percent-encoding, if any
|
||||||
|
name = percentDecode(name)
|
||||||
|
|
||||||
result.append((name, vmDir, isSymlink))
|
result.append((name, vmDir, isSymlink))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +140,11 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||||
}
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry, concurrency: UInt, deduplicate: Bool) async throws {
|
||||||
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageName": name.description], key: "OCI")
|
||||||
|
}
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
|
|
||||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
@@ -130,6 +152,12 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: Digest.hash(manifestData)))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
|
if exists(name) && exists(digestName) && linked(from: name, to: digestName) {
|
||||||
|
// optimistically check if we need to do anything at all before locking
|
||||||
|
defaultLogger.appendNewLine("\(digestName) image is already cached and linked!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||||
@@ -150,7 +178,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
|
||||||
|
|
||||||
// Lock the temporary VM directory to prevent it's garbage collection
|
// Lock the temporary VM directory to prevent it's garbage collection
|
||||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||||
@@ -158,40 +186,41 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
// Try to reclaim some cache space if we know the VM size in advance
|
// Try to reclaim some cache space if we know the VM size in advance
|
||||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
SentrySDK.configureScope { scope in
|
||||||
|
scope.setContext(value: ["imageUncompressedDiskSize": uncompressedDiskSize], key: "OCI")
|
||||||
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
|
|
||||||
let capacityImportant = attrs.volumeAvailableCapacityForImportantUsage!
|
|
||||||
let capacityAvailable = attrs.volumeAvailableCapacity!
|
|
||||||
let availableCapacityBytes = max(UInt64(capacityImportant), UInt64(capacityAvailable))
|
|
||||||
|
|
||||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
|
||||||
SentrySDK.capture(message: "Zero capacity") { scope in
|
|
||||||
scope.setLevel(.warning)
|
|
||||||
|
|
||||||
scope.setContext(value: [
|
|
||||||
"volumeAvailableCapacityForImportantUsageKey": capacityImportant,
|
|
||||||
"volumeAvailableCapacityKey": capacityAvailable,
|
|
||||||
], key: "Attributes")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
let otherVMFilesSize: UInt64 = 128 * 1024 * 1024
|
||||||
// Let's validate to avoid unnecessary pruning.
|
|
||||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
|
||||||
let transaction = SentrySDK.startTransaction(name: "Automatically Pruning Cache", operation: "prune", bindToScope: true)
|
|
||||||
transaction.setData(value: name, key: "name")
|
|
||||||
transaction.setData(value: uncompressedDiskSize, key: "uncompressedDiskSize")
|
|
||||||
transaction.setData(value: availableCapacityBytes, key: "availableCapacity")
|
|
||||||
transaction.setData(value: requiredCapacityBytes, key: "requiredCapacity")
|
|
||||||
defer { transaction.finish() }
|
|
||||||
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.reclaimIfNeeded(uncompressedDiskSize + otherVMFilesSize)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await withTaskCancellationHandler(operation: {
|
try await withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
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 URLError {
|
||||||
|
print("Error: \(error.localizedDescription)")
|
||||||
|
print("Attempting to re-try...")
|
||||||
|
|
||||||
|
return .retry
|
||||||
|
}
|
||||||
|
|
||||||
|
return .throw
|
||||||
|
}
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
transaction.finish()
|
transaction.finish()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
@@ -210,24 +239,87 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
// are excluded from garbage collection
|
// are excluded from garbage collection
|
||||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// to explicitly set the image as being accessed so it won't get pruned immediately
|
||||||
|
_ = try VMStorageOCI().open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func linked(from: RemoteName, to: RemoteName) -> Bool {
|
||||||
|
do {
|
||||||
|
let resolvedFrom = try FileManager.default.destinationOfSymbolicLink(atPath: vmURL(from).path)
|
||||||
|
return resolvedFrom == vmURL(to).path
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func link(from: RemoteName, to: RemoteName) throws {
|
func link(from: RemoteName, to: RemoteName) throws {
|
||||||
if FileManager.default.fileExists(atPath: vmURL(from).path) {
|
try? FileManager.default.removeItem(at: vmURL(from))
|
||||||
try FileManager.default.removeItem(at: vmURL(from))
|
|
||||||
}
|
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
try FileManager.default.createSymbolicLink(at: vmURL(from), withDestinationURL: vmURL(to))
|
||||||
|
|
||||||
try gc()
|
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 {
|
extension URL {
|
||||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||||
var result: URL = self
|
var result: URL = self
|
||||||
|
|
||||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
for pathComponent in (percentEncode(name.host) + "/" + name.namespace + "/" + name.reference.value).split(separator: "/") {
|
||||||
result = result.appendingPathComponent(String(pathComponent))
|
result = result.appendingPathComponent(String(pathComponent))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +327,23 @@ extension URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func appendingHost(_ name: RemoteName) -> URL {
|
func appendingHost(_ name: RemoteName) -> URL {
|
||||||
self.appendingPathComponent(name.host, isDirectory: true)
|
self.appendingPathComponent(percentEncode(name.host), isDirectory: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Work around a pretty inane Swift's URL behavior where calling
|
||||||
|
// appendingPathComponent() or deletingLastPathComponent() on a
|
||||||
|
// URL like URL(filePath: "example.com:8080") (note the "filePath")
|
||||||
|
// will flip its isFileURL from "true" to "false" and discard its
|
||||||
|
// absolute path infromation (if any).
|
||||||
|
//
|
||||||
|
// The same kind of operations won't do anything to a URL like
|
||||||
|
// URL(filePath: "127.0.0.1:8080"), which makes things even more
|
||||||
|
// ridiculous.
|
||||||
|
private func percentEncode(_ s: String) -> String {
|
||||||
|
return s.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: ":").inverted)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentDecode(_ s: String) -> String {
|
||||||
|
s.removingPercentEncoding!
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class FullFledgedVNC: VNC {
|
|||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
while true {
|
while true {
|
||||||
// Port is 0 shortly after start(),
|
// Port is 0 shortly after start(),
|
||||||
// but will be initialized later
|
// but will be initialized later
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ class ScreenSharingVNC: VNC {
|
|||||||
self.vmConfig = vmConfig
|
self.vmConfig = vmConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL(netBridged: Bool) async throws -> URL {
|
||||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: netBridged ? .arp : .dhcp, secondsToWait: 60)
|
||||||
|
|
||||||
if let ip = ip {
|
if let ip = ip {
|
||||||
return URL(string: "vnc://\(ip)")!
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
protocol VNC {
|
protocol VNC {
|
||||||
func waitForURL() async throws -> URL
|
func waitForURL(netBridged: Bool) async throws -> URL
|
||||||
func stop() throws
|
func stop() throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
final class DirectoryShareTests: XCTestCase {
|
||||||
|
func testNamedParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
|
||||||
|
XCTAssertEqual(share.name, "build")
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNamedReadOnlyParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
|
||||||
|
XCTAssertEqual(share.name, "build")
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalNameParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOptionalNameReadOnlyParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(share.readOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMountTagParsing() throws {
|
||||||
|
let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar")
|
||||||
|
XCTAssertNil(share.name)
|
||||||
|
XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertFalse(share.readOnly)
|
||||||
|
XCTAssertEqual(share.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar")
|
||||||
|
XCTAssertNil(roShare.name)
|
||||||
|
XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(roShare.readOnly)
|
||||||
|
XCTAssertEqual(roShare.mountTag, "foo-bar")
|
||||||
|
|
||||||
|
let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro")
|
||||||
|
XCTAssertNil(inverseRoShare.name)
|
||||||
|
XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build"))
|
||||||
|
XCTAssertTrue(inverseRoShare.readOnly)
|
||||||
|
XCTAssertEqual(inverseRoShare.mountTag, "foo-bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testURL() throws {
|
||||||
|
let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz")
|
||||||
|
XCTAssertNil(archiveWithoutNameOrOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertFalse(archiveWithoutNameOrOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag)
|
||||||
|
|
||||||
|
let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertNil(archiveWithOptions.name)
|
||||||
|
XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithOptions.mountTag, "sometag")
|
||||||
|
|
||||||
|
let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.name, "somename")
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!)
|
||||||
|
XCTAssertTrue(archiveWithNameAndOptions.readOnly)
|
||||||
|
XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DockerConfigTests: XCTestCase {
|
||||||
|
func testHelpers() throws {
|
||||||
|
let config = DockerConfig(credHelpers: [
|
||||||
|
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
|
||||||
|
"gcr.io": "gcloud"
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
|
||||||
|
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class LayerizerTests: XCTestCase {
|
||||||
|
var registryRunner: RegistryRunner?
|
||||||
|
|
||||||
|
var registry: Registry {
|
||||||
|
registryRunner!.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUp() async throws {
|
||||||
|
try await super.setUp()
|
||||||
|
|
||||||
|
do {
|
||||||
|
registryRunner = try await RegistryRunner()
|
||||||
|
} catch {
|
||||||
|
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDown() async throws {
|
||||||
|
try await super.tearDown()
|
||||||
|
|
||||||
|
registryRunner = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV1() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await DiskV1.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||||
|
|
||||||
|
print("pulling disk...")
|
||||||
|
try await DiskV1.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDiskV2() async throws {
|
||||||
|
// Original disk file to be pushed to the registry
|
||||||
|
let originalDiskFileURL = try fileWithRandomData(sizeBytes: 5 * 1024 * 1024 * 1024)
|
||||||
|
addTeardownBlock {
|
||||||
|
try FileManager.default.removeItem(at: originalDiskFileURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk file to be pulled from the registry
|
||||||
|
// and compared against the original disk file
|
||||||
|
let pulledDiskFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
|
||||||
|
print("pushing disk...")
|
||||||
|
let diskLayers = try await DiskV2.push(diskURL: originalDiskFileURL, registry: registry, chunkSizeMb: 0, concurrency: 4, progress: Progress())
|
||||||
|
|
||||||
|
print("pulling disk...")
|
||||||
|
try await DiskV2.pull(registry: registry, diskLayers: diskLayers, diskURL: pulledDiskFileURL, concurrency: 16, progress: Progress())
|
||||||
|
|
||||||
|
print("comparing disks...")
|
||||||
|
try XCTAssertEqual(Digest.hash(originalDiskFileURL), Digest.hash(pulledDiskFileURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileWithRandomData(sizeBytes: Int) throws -> URL {
|
||||||
|
let devUrandom = try FileHandle(forReadingFrom: URL(filePath: "/dev/urandom"))
|
||||||
|
|
||||||
|
let temporaryFileURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
FileManager.default.createFile(atPath: temporaryFileURL.path, contents: nil)
|
||||||
|
let temporaryFile = try FileHandle(forWritingTo: temporaryFileURL)
|
||||||
|
|
||||||
|
var remainingBytes = sizeBytes
|
||||||
|
|
||||||
|
while remainingBytes > 0 {
|
||||||
|
let randomData = try devUrandom.read(upToCount: min(64 * 1024 * 1024, remainingBytes))!
|
||||||
|
remainingBytes -= randomData.count
|
||||||
|
try temporaryFile.write(contentsOf: randomData)
|
||||||
|
}
|
||||||
|
|
||||||
|
try devUrandom.close()
|
||||||
|
|
||||||
|
try temporaryFile.close()
|
||||||
|
|
||||||
|
return temporaryFileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftRadix
|
||||||
|
|
||||||
|
final class LeaseTests: XCTestCase {
|
||||||
|
func testCorrectTimezone() throws {
|
||||||
|
let lease = Lease(fromRawLease: [
|
||||||
|
"hw_address": "1,11:22:33:44:55:66",
|
||||||
|
"ip_address": "1.2.3.4",
|
||||||
|
"lease": "0x6565da9e",
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertNotNil(lease)
|
||||||
|
XCTAssertEqual(lease!.expiresAt.toISO(), "2023-11-28T12:18:38Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
import Network
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
final class LeasesTests: XCTestCase {
|
||||||
|
func testNoExpired() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=1.2.3.4
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=whatever
|
||||||
|
ip_address=66.66.66.66
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() - 1.seconds).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("1.2.3.4"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDuplicateYetNotExpiredLeases() throws {
|
||||||
|
let macAddress = MACAddress(fromString: "11:22:33:44:55:66")!
|
||||||
|
|
||||||
|
let leases = try Leases("""
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.1
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 10.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
name=debian
|
||||||
|
ip_address=192.168.64.2
|
||||||
|
hw_address=1,\(macAddress)
|
||||||
|
identifier=1,\(macAddress)
|
||||||
|
lease=\(Int((Date() + 5.minutes).timeIntervalSince1970).hex)
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
|
||||||
|
XCTAssertEqual(IPv4Address("192.168.64.1"), leases.ResolveMACAddress(macAddress: macAddress))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultipleEntries() throws {
|
func testMultipleEntries() throws {
|
||||||
@@ -20,16 +21,18 @@ final class MACAddressResolverTests: XCTestCase {
|
|||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
|
lease=0x7fffffff
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||||
try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,26 @@ import XCTest
|
|||||||
|
|
||||||
final class TokenResponseTests: XCTestCase {
|
final class TokenResponseTests: XCTestCase {
|
||||||
func testBasic() throws {
|
func testBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.token, "some token")
|
XCTAssertEqual(tokenResponse.token, "some token")
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(60)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testExpirationBasic() throws {
|
func testExpirationBasic() throws {
|
||||||
|
var expectedTokenExpiresAtRange = DateInterval()
|
||||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||||
|
|
||||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||||
|
|
||||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
expectedTokenExpiresAtRange.end = Date().addingTimeInterval(2)
|
||||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||||
|
|
||||||
XCTAssertTrue(tokenResponse.isValid())
|
XCTAssertTrue(tokenResponse.isValid())
|
||||||
|
|||||||