mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-10 16:05:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb954b7cc1 | ||
|
|
8cbcd2285b | ||
|
|
3d0d889c99 | ||
|
|
0e77f14dd7 | ||
|
|
39e1b84423 | ||
|
|
af7530ee50 | ||
|
|
36bb68a72d | ||
|
|
afd707eedf | ||
|
|
c67efb88f3 | ||
|
|
667de2a199 | ||
|
|
44650e9713 | ||
|
|
62ee42de3b | ||
|
|
89301d114e | ||
|
|
dbb33d0651 | ||
|
|
166e3e570f | ||
|
|
7a2c20ba30 | ||
|
|
e90d53eceb | ||
|
|
afbc2e0764 | ||
|
|
0229138bd5 | ||
|
|
4d08e6365e | ||
|
|
8cd68ea8ef | ||
|
|
f9001304c8 | ||
|
|
4e20ea8f72 | ||
|
|
678ce0a55a | ||
|
|
8273ae66e1 | ||
|
|
4a9316a377 | ||
|
|
6321d547cf | ||
|
|
f241e21614 | ||
|
|
0eca923604 | ||
|
|
87f29cc11f | ||
|
|
90d1393137 | ||
|
|
625d431d10 | ||
|
|
4648e1aea1 | ||
|
|
4b62b73015 | ||
|
|
ae7018c31f | ||
|
|
e54c89da0c | ||
|
|
9a0ec3e6b0 | ||
|
|
400f85a493 | ||
|
|
0105280b5d | ||
|
|
c063c5bdc2 | ||
|
|
8a2b0151e0 | ||
|
|
2eea51d6ec | ||
|
|
1890909d28 | ||
|
|
0cad2e454c | ||
|
|
17dcf942c1 | ||
|
|
6296df7c0c | ||
|
|
c54b140750 | ||
|
|
048a5506df | ||
|
|
553b36349b | ||
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 | ||
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a | ||
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc |
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
||||
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
||||
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||
+17
-3
@@ -10,7 +10,7 @@ task:
|
||||
name: Build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
@@ -20,12 +20,26 @@ task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
install_script: brew install go goreleaser/tap/goreleaser-pro
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
- security create-keychain -p password101 build.keychain
|
||||
- security default-keychain -s build.keychain
|
||||
- security unlock-keychain -p password101 build.keychain
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
github: [cirruslabs]
|
||||
+14
-3
@@ -7,13 +7,16 @@ builds:
|
||||
goarch:
|
||||
- arm64
|
||||
prebuilt:
|
||||
path: .build/{{ .Arch }}-apple-macosx/debug/tart
|
||||
path: .build/arm64-apple-macosx/debug/tart
|
||||
hooks:
|
||||
post:
|
||||
- gon gon.hcl
|
||||
- .ci/create-pkg.sh
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c debug --product tart
|
||||
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/debug/tart
|
||||
|
||||
archives:
|
||||
- id: binary
|
||||
@@ -24,6 +27,8 @@ archives:
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./dist/Tart-{{ .Tag }}.pkg
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
@@ -32,7 +37,7 @@ brews:
|
||||
tap:
|
||||
owner: cirruslabs
|
||||
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
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
@@ -40,3 +45,9 @@ brews:
|
||||
- "cirruslabs/cli/softnet"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
+54
-63
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
"identity" : "antlr4",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/async-http-client",
|
||||
"location" : "https://github.com/antlr/antlr4",
|
||||
"state" : {
|
||||
"revision" : "24425989dadab6d6e4167174791a23d4e2a6d0c3",
|
||||
"version" : "1.10.0"
|
||||
"branch" : "dev",
|
||||
"revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,6 +18,24 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "puppy",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/sushichop/Puppy",
|
||||
"state" : {
|
||||
"revision" : "3e8d87f714f14244878752a6bb71ea465119f8a1",
|
||||
"version" : "0.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,12 +46,30 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "ce9c0d897db8a840c39de64caaa9b60119cf4be8",
|
||||
"version" : "0.8.1"
|
||||
"branch" : "main",
|
||||
"revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
|
||||
"version" : "1.0.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,71 +77,26 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-log.git",
|
||||
"state" : {
|
||||
"revision" : "5d66f7ba25daf4f94100e7022febf3c75e37a6c7",
|
||||
"version" : "1.4.2"
|
||||
"revision" : "6fe203dc33195667ce1759bf0182975e4653ba1c",
|
||||
"version" : "1.4.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"location" : "https://github.com/apple/swift-numerics",
|
||||
"state" : {
|
||||
"revision" : "124119f0bb12384cef35aa041d7c3a686108722d",
|
||||
"version" : "2.40.0"
|
||||
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-extras",
|
||||
"identity" : "swiftdate",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-extras.git",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "8eea84ec6144167354387ef9244b0939f5852dc8",
|
||||
"version" : "1.11.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-http2",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832",
|
||||
"version" : "1.21.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-ssl",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-ssl.git",
|
||||
"state" : {
|
||||
"revision" : "1750873bce84b4129b5303655cce2c3d35b9ed3a",
|
||||
"version" : "2.19.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-transport-services",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||
"state" : {
|
||||
"revision" : "1a4692acb88156e3da1b0c6732a8a38b2a744166",
|
||||
"version" : "1.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-parsing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-parsing",
|
||||
"state" : {
|
||||
"revision" : "28d32e9ace1c4c43f5e5a177be837a202494c2d5",
|
||||
"version" : "0.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd",
|
||||
"version" : "0.2.1"
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"version" : "6.3.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
+20
-6
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.6
|
||||
// swift-tools-version:5.7
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
@@ -12,17 +12,31 @@ let package = Package(
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
|
||||
.package(url: "https://github.com/swift-server/async-http-client", from: "1.10.0"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
|
||||
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
|
||||
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
|
||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.product(name: "Parsing", package: "swift-parsing"),
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
.product(name: "Puppy", package: "Puppy"),
|
||||
.product(name: "Antlr4Static", package: "Antlr4"),
|
||||
.product(name: "Atomics", package: "swift-atomics"),
|
||||
], exclude: [
|
||||
"OCI/Reference/Makefile",
|
||||
"OCI/Reference/Reference.g4",
|
||||
"OCI/Reference/Generated/Reference.interp",
|
||||
"OCI/Reference/Generated/Reference.tokens",
|
||||
"OCI/Reference/Generated/ReferenceLexer.interp",
|
||||
"OCI/Reference/Generated/ReferenceLexer.tokens",
|
||||
]),
|
||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
@@ -8,18 +8,62 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Built-in CI integration.
|
||||
|
||||
*Tart* is already adopted by several automation services:
|
||||
|
||||
<p align="center">
|
||||
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://codemagic.io/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
|
||||
</a>
|
||||
<a href="https://testingbot.com/" target=_blank>
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Usage
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
|
||||
tart run monterey-base
|
||||
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||
tart run ventura-base
|
||||
```
|
||||
|
||||

|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
|
||||
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||
Please read down below about currently supported services.
|
||||
|
||||
### Managed runners for your CI-as-a-service
|
||||
|
||||
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
|
||||
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
|
||||
|
||||
```yaml
|
||||
name: Test Suite
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
```
|
||||
|
||||
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||
|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
|
||||
|
||||
### Self-hosted CI
|
||||
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||
@@ -48,14 +92,14 @@ brew install cirruslabs/cli/cirrus
|
||||
cirrus run
|
||||
```
|
||||
|
||||

|
||||
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
|
||||
|
||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
### Retrieving artifacts from within Tart VMs
|
||||
#### Retrieving artifacts from within Tart VMs
|
||||
|
||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||
@@ -84,6 +128,11 @@ For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/bina
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
#### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
@@ -100,6 +149,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
@@ -165,10 +235,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
|
||||
|
||||
#### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```shell
|
||||
tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
...or instantiate a VM from a remote image:
|
||||
|
||||
```shell
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
@@ -186,8 +266,8 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
<details>
|
||||
<summary>Why Tart is free and open sourced?</summary>
|
||||
|
||||
Tart is a relatively small project, and it didn't feel right to try to monetize it.
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
|
||||
Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -216,3 +296,21 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
doesn't support nested virtualization.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Changing the default NAT subnet</summary>
|
||||
|
||||
To change the default network to `192.168.77.1`:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
|
||||
```
|
||||
|
||||
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
|
||||
|
||||
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
|
||||
|
||||
```
|
||||
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
|
||||
size 155885
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
|
||||
size 205325
|
||||
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
|
||||
size 1359834
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
|
||||
size 6787
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
|
||||
size 14588
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
|
||||
size 18393
|
||||
@@ -5,4 +5,4 @@
|
||||
<key>com.apple.security.virtualization</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -11,6 +11,9 @@ struct Clone: AsyncParsableCommand {
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
@@ -24,17 +27,27 @@ struct Clone: AsyncParsableCommand {
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
let sourceVM = try VMStorageHelper.open(sourceName)
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
let lock = try FileLock(lockURL: Config().tartHomeDir)
|
||||
try lock.lock()
|
||||
|
||||
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
|
||||
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
|
||||
try localStorage.move(newName, from: tmpVMDir)
|
||||
|
||||
try lock.unlock()
|
||||
}, onCancel: {
|
||||
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
|
||||
})
|
||||
|
||||
@@ -9,26 +9,50 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file or URL (or \"latest\", to fetch the latest supported IPSW automatically)", valueName: "path"))
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
throw ValidationError("Please specify a --from-ipsw option!")
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if fromIPSW! == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
|
||||
if let fromIPSW = fromIPSW {
|
||||
let ipswURL: URL
|
||||
|
||||
if fromIPSW == "latest" {
|
||||
ipswURL = try await VM.latestIPSWURL()
|
||||
} else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
|
||||
ipswURL = URL(string: fromIPSW)!
|
||||
} else {
|
||||
ipswURL = URL(fileURLWithPath: fromIPSW)
|
||||
}
|
||||
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -6,11 +6,13 @@ struct Delete: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Delete a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
var name: [String]
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
try VMStorageHelper.delete(name)
|
||||
for it in name {
|
||||
try VMStorageHelper.delete(it)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
|
||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
print(ip)
|
||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -33,16 +40,16 @@ struct IP: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let leases = try Leases(), let ip = try leases.resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
// wait a second
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} while Date.now < waitUntil
|
||||
|
||||
return nil
|
||||
|
||||
@@ -5,12 +5,17 @@ import SwiftUI
|
||||
struct List: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||
|
||||
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
|
||||
var quiet: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
print("Source\tName")
|
||||
if !quiet {
|
||||
print("Source\tName")
|
||||
}
|
||||
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
displayTable("oci", try VMStorageOCI().list())
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -22,7 +27,11 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
||||
print("\(source)\t\(name)")
|
||||
if quiet {
|
||||
print(name)
|
||||
} else {
|
||||
print("\(source)\t\(name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ struct Login: AsyncParsableCommand {
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
@@ -41,7 +44,8 @@ struct Login: AsyncParsableCommand {
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider)
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||
|
||||
@Option(help: ArgumentHelp("Remove cache entries 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.",
|
||||
valueName: "n"))
|
||||
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",
|
||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||
valueName: "n"))
|
||||
var cacheBudget: UInt?
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var gc: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if olderThan == nil && cacheBudget == nil && !gc {
|
||||
throw ValidationError("at least one pruning criteria must be specified")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||
|
||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||
|
||||
var cacheBudgetBytes = cacheBudgetBytes
|
||||
var prunablesToDelete: [Prunable] = []
|
||||
|
||||
for prunable in prunables {
|
||||
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
|
||||
|
||||
if prunableSizeBytes <= cacheBudgetBytes {
|
||||
// Don't mark for deletion as
|
||||
// there's a budget available
|
||||
cacheBudgetBytes -= prunableSizeBytes
|
||||
} else {
|
||||
// Mark for deletion
|
||||
prunablesToDelete.append(prunable)
|
||||
}
|
||||
}
|
||||
|
||||
try prunablesToDelete.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
|
||||
var it = prunables.makeIterator()
|
||||
|
||||
while cacheReclaimedBytes <= reclaimBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
}
|
||||
|
||||
cacheReclaimedBytes += try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
puppy.info("deleting \(prunable.url)...")
|
||||
}
|
||||
|
||||
puppy.info("reclaimed \(cacheReclaimedBytes) bytes")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ struct Pull: AsyncParsableCommand {
|
||||
@Argument(help: "remote VM name")
|
||||
var remoteName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@ struct Push: AsyncParsableCommand {
|
||||
@Argument(help: "remote VM name(s)")
|
||||
var remoteNames: [String]
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||
discussion: """
|
||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
|
||||
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
|
||||
"""))
|
||||
var chunkSize: Int = 0
|
||||
|
||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
@@ -37,12 +48,17 @@ struct Push: AsyncParsableCommand {
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace)
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||
insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference.value })
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
struct Rename: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localStorage = VMStorageLocal()
|
||||
|
||||
if !localStorage.exists(name) {
|
||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||
}
|
||||
|
||||
if localStorage.exists(newName) {
|
||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
||||
}
|
||||
|
||||
try localStorage.rename(name, newName)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ struct Run: AsyncParsableCommand {
|
||||
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
|
||||
var noGraphics: Bool = false
|
||||
|
||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
||||
var graphics: Bool = false
|
||||
|
||||
@Flag(help: "Boot into recovery mode")
|
||||
var recovery: Bool = false
|
||||
|
||||
@@ -34,20 +37,54 @@ struct Run: AsyncParsableCommand {
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Flag var withSoftnet: Bool = false
|
||||
@Flag(help: ArgumentHelp(visibility: .private))
|
||||
var withSoftnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\")
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||
""", discussion: """
|
||||
Learn how to create a disk image using Disk Utility here:
|
||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||
"""))
|
||||
""", valueName: "path[:ro]"))
|
||||
var disk: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
||||
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
|
||||
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||
""", valueName: "name:path[:ro]"))
|
||||
var dir: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
||||
""", discussion: """
|
||||
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||
""", valueName: "interface name"))
|
||||
var netBridged: String?
|
||||
|
||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
||||
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
|
||||
var netSoftnet: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
|
||||
if withSoftnet && netBridged != nil {
|
||||
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
|
||||
}
|
||||
|
||||
if netBridged != nil && netSoftnet {
|
||||
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
|
||||
}
|
||||
|
||||
if graphics && noGraphics {
|
||||
throw ValidationError("--graphics and --no-graphics are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -55,8 +92,9 @@ struct Run: AsyncParsableCommand {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
withSoftnet: withSoftnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments()
|
||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||
additionalDiskAttachments: additionalDiskAttachments(),
|
||||
directoryShares: directoryShares()
|
||||
)
|
||||
|
||||
let vncImpl: VNC? = try {
|
||||
@@ -107,18 +145,61 @@ struct Run: AsyncParsableCommand {
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
if noGraphics || vnc || vncExperimental {
|
||||
let useVNCWithoutGraphics = (vnc || vncExperimental) && !graphics
|
||||
if noGraphics || useVNCWithoutGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
}
|
||||
|
||||
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
|
||||
if withSoftnet || netSoftnet {
|
||||
let config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
return try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
if let netBridged = netBridged {
|
||||
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
|
||||
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
|
||||
}
|
||||
|
||||
if matchingInterfaces.isEmpty {
|
||||
let available = bridgeInterfaces().joined(separator: ", ")
|
||||
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
|
||||
+ "available interfaces: \(available)")
|
||||
}
|
||||
|
||||
if matchingInterfaces.count > 1 {
|
||||
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
|
||||
+ "consider refining the search criteria")
|
||||
}
|
||||
|
||||
return NetworkBridged(interface: matchingInterfaces.first!)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bridgeInterfaces() -> [String] {
|
||||
VZBridgedNetworkInterface.networkInterfaces.map { interface in
|
||||
var bridgeDescription = interface.identifier
|
||||
|
||||
if let localizedDisplayName = interface.localizedDisplayName {
|
||||
bridgeDescription += " (or \"\(localizedDisplayName)\")"
|
||||
}
|
||||
|
||||
return bridgeDescription
|
||||
}
|
||||
}
|
||||
|
||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||
let readOnlySuffix = ":ro"
|
||||
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
|
||||
|
||||
for rawDisk in disk {
|
||||
for rawDisk in expandedDiskPaths {
|
||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||
@@ -135,6 +216,34 @@ struct Run: AsyncParsableCommand {
|
||||
return result
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [DirectoryShare] {
|
||||
var result: [DirectoryShare] = []
|
||||
|
||||
for rawDir in dir {
|
||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||
|
||||
if splits.count < 2 {
|
||||
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
|
||||
}
|
||||
|
||||
var readOnly: Bool = false
|
||||
|
||||
if splits.count == 3 {
|
||||
if splits[2] == "ro" {
|
||||
readOnly = true
|
||||
} else {
|
||||
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
|
||||
}
|
||||
}
|
||||
|
||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
@@ -148,6 +257,8 @@ struct Run: AsyncParsableCommand {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
|
||||
@@ -3,8 +3,9 @@ import Foundation
|
||||
struct Config {
|
||||
let tartHomeDir: URL
|
||||
let tartCacheDir: URL
|
||||
let tartTmpDir: URL
|
||||
|
||||
init() {
|
||||
init() throws {
|
||||
var tartHomeDir: URL
|
||||
|
||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||
@@ -14,9 +15,27 @@ struct Config {
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
}
|
||||
|
||||
self.tartHomeDir = tartHomeDir
|
||||
|
||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
|
||||
|
||||
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func gc() throws {
|
||||
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||
includingPropertiesForKeys: [], options: []) {
|
||||
let lock = try FileLock(lockURL: entry)
|
||||
if try !lock.trylock() {
|
||||
continue
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: entry)
|
||||
|
||||
try lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
static func jsonEncoder() -> JSONEncoder {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||
return nil
|
||||
}
|
||||
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
|
||||
|
||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||
return credentialsFromAuth
|
||||
}
|
||||
if let helperProgram = config.credHelpers?[host] {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process.init()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["get"]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = outPipe
|
||||
process.standardInput = inPipe
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerConfig: Codable {
|
||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||
}
|
||||
|
||||
struct DockerAuthConfig: Codable {
|
||||
var auth: String? = nil
|
||||
|
||||
func decodeCredentials() -> (String, String)? {
|
||||
// auth is a base64("username:password")
|
||||
guard let authBase64 = auth else {
|
||||
return nil
|
||||
}
|
||||
guard let data = Data(base64Encoded: authBase64) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
||||
return nil
|
||||
}
|
||||
if components.count != 2 {
|
||||
return nil
|
||||
}
|
||||
return (components[0], components[1])
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerGetOutput: Codable {
|
||||
var Username: String
|
||||
var Secret: String
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import AsyncAlgorithms
|
||||
|
||||
class Fetcher: NSObject, URLSessionTaskDelegate, URLSessionDelegate, URLSessionDataDelegate {
|
||||
let responseCh = AsyncThrowingChannel<URLResponse, Error>()
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
func fetch(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, URLResponse) {
|
||||
let task = URLSession.shared.dataTask(with: request)
|
||||
task.delegate = self
|
||||
task.resume()
|
||||
|
||||
// Wait for the response and only then return
|
||||
var iter = responseCh.makeAsyncIterator()
|
||||
let response = try await iter.next()!
|
||||
|
||||
return (dataCh, response)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition {
|
||||
await responseCh.send(response)
|
||||
|
||||
return .allow
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
let sema = DispatchSemaphore(value: 0)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
sema.signal()
|
||||
}
|
||||
|
||||
sema.wait()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
// Premature termination
|
||||
responseCh.fail(error)
|
||||
dataCh.fail(error)
|
||||
} else {
|
||||
dataCh.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import System
|
||||
|
||||
enum FileLockError: Error, Equatable {
|
||||
case Failed(_ message: String)
|
||||
case AlreadyLocked
|
||||
}
|
||||
|
||||
class FileLock {
|
||||
let url: URL
|
||||
let fd: Int32
|
||||
|
||||
init(lockURL: URL) throws {
|
||||
url = lockURL
|
||||
fd = open(lockURL.path, 0)
|
||||
}
|
||||
|
||||
deinit {
|
||||
close(fd)
|
||||
}
|
||||
|
||||
func trylock() throws -> Bool {
|
||||
try flockWrapper(LOCK_EX | LOCK_NB)
|
||||
}
|
||||
|
||||
func lock() throws {
|
||||
_ = try flockWrapper(LOCK_EX)
|
||||
}
|
||||
|
||||
func unlock() throws {
|
||||
_ = try flockWrapper(LOCK_UN)
|
||||
}
|
||||
|
||||
func flockWrapper(_ operation: Int32) throws -> Bool {
|
||||
let ret = flock(fd, operation)
|
||||
if ret != 0 {
|
||||
let details = Errno(rawValue: CInt(errno))
|
||||
|
||||
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
|
||||
return false
|
||||
}
|
||||
|
||||
throw FileLockError.Failed("failed to lock \(url): \(details)")
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class IPSWCache: PrunableStorage {
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func locationFor(fileName: String) -> URL {
|
||||
baseURL.appendingPathComponent(fileName, isDirectory: false)
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -19,14 +19,21 @@ enum LeasesError: Error {
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init() throws {
|
||||
convenience init?() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init(_ fromURL: URL) throws {
|
||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
convenience init?(_ fromURL: URL) throws {
|
||||
do {
|
||||
let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
try self.init(urlContents)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
return nil
|
||||
}
|
||||
|
||||
try self.init(fileContents)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
|
||||
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment
|
||||
func run(_ sema: DispatchSemaphore) throws
|
||||
func stop() async throws
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkBridged: Network {
|
||||
let interface: VZBridgedNetworkInterface
|
||||
|
||||
init(interface: VZBridgedNetworkInterface) {
|
||||
self.interface = interface
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class NetworkShared: Network {
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
// no-op, only used for Softnet
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import Atomics
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
case RuntimeFailed(why: String)
|
||||
}
|
||||
|
||||
class Softnet {
|
||||
class Softnet: Network {
|
||||
private let process = Process()
|
||||
private var monitorTask: Task<Void, Error>? = nil
|
||||
private let monitorTaskFinished = ManagedAtomic<Bool>(false)
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String) throws {
|
||||
let binaryName = "softnet"
|
||||
|
||||
guard let executableURL = Self.resolveBinaryPath(binaryName) else {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
@@ -34,30 +39,33 @@ class Softnet {
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
func run(_ sema: DispatchSemaphore) throws {
|
||||
try process.run()
|
||||
|
||||
monitorTask = Task {
|
||||
// Wait for the Softnet to finish
|
||||
process.waitUntilExit()
|
||||
|
||||
// Signal to the caller that the Softnet has finished
|
||||
sema.signal()
|
||||
|
||||
// Signal to ourselves that the Softnet has finished
|
||||
monitorTaskFinished.store(true, ordering: .sequentiallyConsistent)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
process.interrupt()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
func stop() async throws {
|
||||
if monitorTaskFinished.load(ordering: .sequentiallyConsistent) {
|
||||
// Consume the monitor task's value to ensure the task has finished
|
||||
_ = try await monitorTask?.value
|
||||
|
||||
private static func resolveBinaryPath(_ name: String) -> URL? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||
return nil
|
||||
throw SoftnetError.RuntimeFailed(why: "Softnet process terminated prematurely")
|
||||
} else {
|
||||
process.interrupt()
|
||||
|
||||
// Consume the monitor task's value to ensure the task has finished
|
||||
_ = try await monitorTask?.value
|
||||
}
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
@@ -74,4 +82,9 @@ class Softnet {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
}
|
||||
|
||||
func attachment() -> VZNetworkDeviceAttachment {
|
||||
let fh = FileHandle.init(fileDescriptor: vmFD)
|
||||
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
root
|
||||
host
|
||||
port
|
||||
host_component
|
||||
namespace
|
||||
namespace_component
|
||||
reference
|
||||
tag
|
||||
separator
|
||||
name
|
||||
|
||||
|
||||
atn:
|
||||
[4, 1, 8, 95, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 2, 8, 7, 8, 2, 9, 7, 9, 1, 0, 1, 0, 1, 0, 3, 0, 24, 8, 0, 1, 0, 1, 0, 1, 0, 3, 0, 29, 8, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 5, 1, 36, 8, 1, 10, 1, 12, 1, 39, 9, 1, 1, 2, 4, 2, 42, 8, 2, 11, 2, 12, 2, 43, 1, 3, 1, 3, 1, 3, 5, 3, 49, 8, 3, 10, 3, 12, 3, 52, 9, 3, 1, 4, 1, 4, 1, 4, 5, 4, 57, 8, 4, 10, 4, 12, 4, 60, 9, 4, 1, 5, 1, 5, 3, 5, 64, 8, 5, 4, 5, 66, 8, 5, 11, 5, 12, 5, 67, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 1, 6, 3, 6, 77, 8, 6, 1, 7, 1, 7, 1, 7, 1, 7, 5, 7, 83, 8, 7, 10, 7, 12, 7, 86, 9, 7, 1, 8, 1, 8, 1, 9, 4, 9, 91, 8, 9, 11, 9, 12, 9, 92, 1, 9, 0, 0, 10, 0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 0, 2, 2, 0, 3, 4, 6, 6, 1, 0, 7, 8, 95, 0, 20, 1, 0, 0, 0, 2, 32, 1, 0, 0, 0, 4, 41, 1, 0, 0, 0, 6, 45, 1, 0, 0, 0, 8, 53, 1, 0, 0, 0, 10, 65, 1, 0, 0, 0, 12, 76, 1, 0, 0, 0, 14, 78, 1, 0, 0, 0, 16, 87, 1, 0, 0, 0, 18, 90, 1, 0, 0, 0, 20, 23, 3, 2, 1, 0, 21, 22, 5, 1, 0, 0, 22, 24, 3, 4, 2, 0, 23, 21, 1, 0, 0, 0, 23, 24, 1, 0, 0, 0, 24, 25, 1, 0, 0, 0, 25, 26, 5, 2, 0, 0, 26, 28, 3, 8, 4, 0, 27, 29, 3, 12, 6, 0, 28, 27, 1, 0, 0, 0, 28, 29, 1, 0, 0, 0, 29, 30, 1, 0, 0, 0, 30, 31, 5, 0, 0, 1, 31, 1, 1, 0, 0, 0, 32, 37, 3, 6, 3, 0, 33, 34, 5, 3, 0, 0, 34, 36, 3, 6, 3, 0, 35, 33, 1, 0, 0, 0, 36, 39, 1, 0, 0, 0, 37, 35, 1, 0, 0, 0, 37, 38, 1, 0, 0, 0, 38, 3, 1, 0, 0, 0, 39, 37, 1, 0, 0, 0, 40, 42, 5, 7, 0, 0, 41, 40, 1, 0, 0, 0, 42, 43, 1, 0, 0, 0, 43, 41, 1, 0, 0, 0, 43, 44, 1, 0, 0, 0, 44, 5, 1, 0, 0, 0, 45, 50, 3, 18, 9, 0, 46, 47, 5, 4, 0, 0, 47, 49, 3, 18, 9, 0, 48, 46, 1, 0, 0, 0, 49, 52, 1, 0, 0, 0, 50, 48, 1, 0, 0, 0, 50, 51, 1, 0, 0, 0, 51, 7, 1, 0, 0, 0, 52, 50, 1, 0, 0, 0, 53, 58, 3, 10, 5, 0, 54, 55, 5, 2, 0, 0, 55, 57, 3, 10, 5, 0, 56, 54, 1, 0, 0, 0, 57, 60, 1, 0, 0, 0, 58, 56, 1, 0, 0, 0, 58, 59, 1, 0, 0, 0, 59, 9, 1, 0, 0, 0, 60, 58, 1, 0, 0, 0, 61, 63, 3, 18, 9, 0, 62, 64, 3, 16, 8, 0, 63, 62, 1, 0, 0, 0, 63, 64, 1, 0, 0, 0, 64, 66, 1, 0, 0, 0, 65, 61, 1, 0, 0, 0, 66, 67, 1, 0, 0, 0, 67, 65, 1, 0, 0, 0, 67, 68, 1, 0, 0, 0, 68, 11, 1, 0, 0, 0, 69, 70, 5, 1, 0, 0, 70, 77, 3, 14, 7, 0, 71, 72, 5, 5, 0, 0, 72, 73, 3, 18, 9, 0, 73, 74, 5, 1, 0, 0, 74, 75, 3, 18, 9, 0, 75, 77, 1, 0, 0, 0, 76, 69, 1, 0, 0, 0, 76, 71, 1, 0, 0, 0, 77, 13, 1, 0, 0, 0, 78, 84, 3, 18, 9, 0, 79, 80, 3, 16, 8, 0, 80, 81, 3, 18, 9, 0, 81, 83, 1, 0, 0, 0, 82, 79, 1, 0, 0, 0, 83, 86, 1, 0, 0, 0, 84, 82, 1, 0, 0, 0, 84, 85, 1, 0, 0, 0, 85, 15, 1, 0, 0, 0, 86, 84, 1, 0, 0, 0, 87, 88, 7, 0, 0, 0, 88, 17, 1, 0, 0, 0, 89, 91, 7, 1, 0, 0, 90, 89, 1, 0, 0, 0, 91, 92, 1, 0, 0, 0, 92, 90, 1, 0, 0, 0, 92, 93, 1, 0, 0, 0, 93, 19, 1, 0, 0, 0, 11, 23, 28, 37, 43, 50, 58, 63, 67, 76, 84, 92]
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,167 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
|
||||
import Antlr4
|
||||
|
||||
|
||||
/**
|
||||
* This class provides an empty implementation of {@link ReferenceListener},
|
||||
* which can be extended to create a listener which only needs to handle a subset
|
||||
* of the available methods.
|
||||
*/
|
||||
open class ReferenceBaseListener: ReferenceListener {
|
||||
public init() { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitHost(_ ctx: ReferenceParser.HostContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterPort(_ ctx: ReferenceParser.PortContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitPort(_ ctx: ReferenceParser.PortContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitTag(_ ctx: ReferenceParser.TagContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterName(_ ctx: ReferenceParser.NameContext) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitName(_ ctx: ReferenceParser.NameContext) { }
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitTerminal(_ node: TerminalNode) { }
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>The default implementation does nothing.</p>
|
||||
*/
|
||||
open func visitErrorNode(_ node: ErrorNode) { }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
token literal names:
|
||||
null
|
||||
':'
|
||||
'/'
|
||||
'.'
|
||||
'-'
|
||||
'@'
|
||||
'_'
|
||||
null
|
||||
null
|
||||
|
||||
token symbolic names:
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
null
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
rule names:
|
||||
T__0
|
||||
T__1
|
||||
T__2
|
||||
T__3
|
||||
T__4
|
||||
T__5
|
||||
DIGIT
|
||||
LETTER
|
||||
|
||||
channel names:
|
||||
DEFAULT_TOKEN_CHANNEL
|
||||
HIDDEN
|
||||
|
||||
mode names:
|
||||
DEFAULT_MODE
|
||||
|
||||
atn:
|
||||
[4, 0, 8, 33, 6, -1, 2, 0, 7, 0, 2, 1, 7, 1, 2, 2, 7, 2, 2, 3, 7, 3, 2, 4, 7, 4, 2, 5, 7, 5, 2, 6, 7, 6, 2, 7, 7, 7, 1, 0, 1, 0, 1, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 3, 1, 4, 1, 4, 1, 5, 1, 5, 1, 6, 1, 6, 1, 7, 1, 7, 0, 0, 8, 1, 1, 3, 2, 5, 3, 7, 4, 9, 5, 11, 6, 13, 7, 15, 8, 1, 0, 2, 1, 0, 48, 57, 2, 0, 65, 90, 97, 122, 32, 0, 1, 1, 0, 0, 0, 0, 3, 1, 0, 0, 0, 0, 5, 1, 0, 0, 0, 0, 7, 1, 0, 0, 0, 0, 9, 1, 0, 0, 0, 0, 11, 1, 0, 0, 0, 0, 13, 1, 0, 0, 0, 0, 15, 1, 0, 0, 0, 1, 17, 1, 0, 0, 0, 3, 19, 1, 0, 0, 0, 5, 21, 1, 0, 0, 0, 7, 23, 1, 0, 0, 0, 9, 25, 1, 0, 0, 0, 11, 27, 1, 0, 0, 0, 13, 29, 1, 0, 0, 0, 15, 31, 1, 0, 0, 0, 17, 18, 5, 58, 0, 0, 18, 2, 1, 0, 0, 0, 19, 20, 5, 47, 0, 0, 20, 4, 1, 0, 0, 0, 21, 22, 5, 46, 0, 0, 22, 6, 1, 0, 0, 0, 23, 24, 5, 45, 0, 0, 24, 8, 1, 0, 0, 0, 25, 26, 5, 64, 0, 0, 26, 10, 1, 0, 0, 0, 27, 28, 5, 95, 0, 0, 28, 12, 1, 0, 0, 0, 29, 30, 7, 0, 0, 0, 30, 14, 1, 0, 0, 0, 31, 32, 7, 1, 0, 0, 32, 16, 1, 0, 0, 0, 1, 0, 0]
|
||||
@@ -0,0 +1,90 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceLexer: Lexer {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceLexer._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceLexer._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
static let T__0=1, T__1=2, T__2=3, T__3=4, T__4=5, T__5=6, DIGIT=7, LETTER=8
|
||||
|
||||
public
|
||||
static let channelNames: [String] = [
|
||||
"DEFAULT_TOKEN_CHANNEL", "HIDDEN"
|
||||
]
|
||||
|
||||
public
|
||||
static let modeNames: [String] = [
|
||||
"DEFAULT_MODE"
|
||||
]
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"T__0", "T__1", "T__2", "T__3", "T__4", "T__5", "DIGIT", "LETTER"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceLexer.VOCABULARY
|
||||
}
|
||||
|
||||
public
|
||||
required init(_ input: CharStream) {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
super.init(input)
|
||||
_interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
|
||||
}
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "Reference.g4" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
|
||||
|
||||
override open
|
||||
func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
|
||||
|
||||
override open
|
||||
func getModeNames() -> [String] { return ReferenceLexer.modeNames }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceLexer._ATN }
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
4,0,8,33,6,-1,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,
|
||||
2,7,7,7,1,0,1,0,1,1,1,1,1,2,1,2,1,3,1,3,1,4,1,4,1,5,1,5,1,6,1,6,1,7,1,
|
||||
7,0,0,8,1,1,3,2,5,3,7,4,9,5,11,6,13,7,15,8,1,0,2,1,0,48,57,2,0,65,90,97,
|
||||
122,32,0,1,1,0,0,0,0,3,1,0,0,0,0,5,1,0,0,0,0,7,1,0,0,0,0,9,1,0,0,0,0,11,
|
||||
1,0,0,0,0,13,1,0,0,0,0,15,1,0,0,0,1,17,1,0,0,0,3,19,1,0,0,0,5,21,1,0,0,
|
||||
0,7,23,1,0,0,0,9,25,1,0,0,0,11,27,1,0,0,0,13,29,1,0,0,0,15,31,1,0,0,0,
|
||||
17,18,5,58,0,0,18,2,1,0,0,0,19,20,5,47,0,0,20,4,1,0,0,0,21,22,5,46,0,0,
|
||||
22,6,1,0,0,0,23,24,5,45,0,0,24,8,1,0,0,0,25,26,5,64,0,0,26,10,1,0,0,0,
|
||||
27,28,5,95,0,0,28,12,1,0,0,0,29,30,7,0,0,0,30,14,1,0,0,0,31,32,7,1,0,0,
|
||||
32,16,1,0,0,0,1,0,0
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
T__0=1
|
||||
T__1=2
|
||||
T__2=3
|
||||
T__3=4
|
||||
T__4=5
|
||||
T__5=6
|
||||
DIGIT=7
|
||||
LETTER=8
|
||||
':'=1
|
||||
'/'=2
|
||||
'.'=3
|
||||
'-'=4
|
||||
'@'=5
|
||||
'_'=6
|
||||
@@ -0,0 +1,129 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
/**
|
||||
* This interface defines a complete listener for a parse tree produced by
|
||||
* {@link ReferenceParser}.
|
||||
*/
|
||||
public protocol ReferenceListener: ParseTreeListener {
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#root}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitRoot(_ ctx: ReferenceParser.RootContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#host}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitHost(_ ctx: ReferenceParser.HostContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterPort(_ ctx: ReferenceParser.PortContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#port}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitPort(_ ctx: ReferenceParser.PortContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#host_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#host_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#namespace}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#reference}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitReference(_ ctx: ReferenceParser.ReferenceContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#tag}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitTag(_ ctx: ReferenceParser.TagContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#separator}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
|
||||
/**
|
||||
* Enter a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func enterName(_ ctx: ReferenceParser.NameContext)
|
||||
/**
|
||||
* Exit a parse tree produced by {@link ReferenceParser#name}.
|
||||
- Parameters:
|
||||
- ctx: the parse tree
|
||||
*/
|
||||
func exitName(_ ctx: ReferenceParser.NameContext)
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
// Generated from java-escape by ANTLR 4.11.1
|
||||
import Antlr4
|
||||
|
||||
open class ReferenceParser: Parser {
|
||||
|
||||
internal static var _decisionToDFA: [DFA] = {
|
||||
var decisionToDFA = [DFA]()
|
||||
let length = ReferenceParser._ATN.getNumberOfDecisions()
|
||||
for i in 0..<length {
|
||||
decisionToDFA.append(DFA(ReferenceParser._ATN.getDecisionState(i)!, i))
|
||||
}
|
||||
return decisionToDFA
|
||||
}()
|
||||
|
||||
internal static let _sharedContextCache = PredictionContextCache()
|
||||
|
||||
public
|
||||
enum Tokens: Int {
|
||||
case EOF = -1, T__0 = 1, T__1 = 2, T__2 = 3, T__3 = 4, T__4 = 5, T__5 = 6,
|
||||
DIGIT = 7, LETTER = 8
|
||||
}
|
||||
|
||||
public
|
||||
static let RULE_root = 0, RULE_host = 1, RULE_port = 2, RULE_host_component = 3,
|
||||
RULE_namespace = 4, RULE_namespace_component = 5, RULE_reference = 6,
|
||||
RULE_tag = 7, RULE_separator = 8, RULE_name = 9
|
||||
|
||||
public
|
||||
static let ruleNames: [String] = [
|
||||
"root", "host", "port", "host_component", "namespace", "namespace_component",
|
||||
"reference", "tag", "separator", "name"
|
||||
]
|
||||
|
||||
private static let _LITERAL_NAMES: [String?] = [
|
||||
nil, "':'", "'/'", "'.'", "'-'", "'@'", "'_'"
|
||||
]
|
||||
private static let _SYMBOLIC_NAMES: [String?] = [
|
||||
nil, nil, nil, nil, nil, nil, nil, "DIGIT", "LETTER"
|
||||
]
|
||||
public
|
||||
static let VOCABULARY = Vocabulary(_LITERAL_NAMES, _SYMBOLIC_NAMES)
|
||||
|
||||
override open
|
||||
func getGrammarFileName() -> String { return "java-escape" }
|
||||
|
||||
override open
|
||||
func getRuleNames() -> [String] { return ReferenceParser.ruleNames }
|
||||
|
||||
override open
|
||||
func getSerializedATN() -> [Int] { return ReferenceParser._serializedATN }
|
||||
|
||||
override open
|
||||
func getATN() -> ATN { return ReferenceParser._ATN }
|
||||
|
||||
|
||||
override open
|
||||
func getVocabulary() -> Vocabulary {
|
||||
return ReferenceParser.VOCABULARY
|
||||
}
|
||||
|
||||
override public
|
||||
init(_ input:TokenStream) throws {
|
||||
RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
|
||||
try super.init(input)
|
||||
_interp = ParserATNSimulator(self,ReferenceParser._ATN,ReferenceParser._decisionToDFA, ReferenceParser._sharedContextCache)
|
||||
}
|
||||
|
||||
|
||||
public class RootContext: ParserRuleContext {
|
||||
open
|
||||
func host() -> HostContext? {
|
||||
return getRuleContext(HostContext.self, 0)
|
||||
}
|
||||
open
|
||||
func namespace() -> NamespaceContext? {
|
||||
return getRuleContext(NamespaceContext.self, 0)
|
||||
}
|
||||
open
|
||||
func EOF() -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.EOF.rawValue, 0)
|
||||
}
|
||||
open
|
||||
func port() -> PortContext? {
|
||||
return getRuleContext(PortContext.self, 0)
|
||||
}
|
||||
open
|
||||
func reference() -> ReferenceContext? {
|
||||
return getRuleContext(ReferenceContext.self, 0)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_root
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterRoot(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitRoot(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func root() throws -> RootContext {
|
||||
var _localctx: RootContext
|
||||
_localctx = RootContext(_ctx, getState())
|
||||
try enterRule(_localctx, 0, ReferenceParser.RULE_root)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(20)
|
||||
try host()
|
||||
setState(23)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue) {
|
||||
setState(21)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(22)
|
||||
try port()
|
||||
|
||||
}
|
||||
|
||||
setState(25)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(26)
|
||||
try namespace()
|
||||
setState(28)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if (_la == ReferenceParser.Tokens.T__0.rawValue || _la == ReferenceParser.Tokens.T__4.rawValue) {
|
||||
setState(27)
|
||||
try reference()
|
||||
|
||||
}
|
||||
|
||||
setState(30)
|
||||
try match(ReferenceParser.Tokens.EOF.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class HostContext: ParserRuleContext {
|
||||
open
|
||||
func host_component() -> [Host_componentContext] {
|
||||
return getRuleContexts(Host_componentContext.self)
|
||||
}
|
||||
open
|
||||
func host_component(_ i: Int) -> Host_componentContext? {
|
||||
return getRuleContext(Host_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_host
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterHost(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitHost(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func host() throws -> HostContext {
|
||||
var _localctx: HostContext
|
||||
_localctx = HostContext(_ctx, getState())
|
||||
try enterRule(_localctx, 2, ReferenceParser.RULE_host)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(32)
|
||||
try host_component()
|
||||
setState(37)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__2.rawValue) {
|
||||
setState(33)
|
||||
try match(ReferenceParser.Tokens.T__2.rawValue)
|
||||
setState(34)
|
||||
try host_component()
|
||||
|
||||
|
||||
setState(39)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class PortContext: ParserRuleContext {
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_port
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterPort(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitPort(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func port() throws -> PortContext {
|
||||
var _localctx: PortContext
|
||||
_localctx = PortContext(_ctx, getState())
|
||||
try enterRule(_localctx, 4, ReferenceParser.RULE_port)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(41)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(40)
|
||||
try match(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
|
||||
setState(43);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class Host_componentContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_host_component
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterHost_component(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitHost_component(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func host_component() throws -> Host_componentContext {
|
||||
var _localctx: Host_componentContext
|
||||
_localctx = Host_componentContext(_ctx, getState())
|
||||
try enterRule(_localctx, 6, ReferenceParser.RULE_host_component)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(45)
|
||||
try name()
|
||||
setState(50)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__3.rawValue) {
|
||||
setState(46)
|
||||
try match(ReferenceParser.Tokens.T__3.rawValue)
|
||||
setState(47)
|
||||
try name()
|
||||
|
||||
|
||||
setState(52)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NamespaceContext: ParserRuleContext {
|
||||
open
|
||||
func namespace_component() -> [Namespace_componentContext] {
|
||||
return getRuleContexts(Namespace_componentContext.self)
|
||||
}
|
||||
open
|
||||
func namespace_component(_ i: Int) -> Namespace_componentContext? {
|
||||
return getRuleContext(Namespace_componentContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace() throws -> NamespaceContext {
|
||||
var _localctx: NamespaceContext
|
||||
_localctx = NamespaceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 8, ReferenceParser.RULE_namespace)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(53)
|
||||
try namespace_component()
|
||||
setState(58)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while (_la == ReferenceParser.Tokens.T__1.rawValue) {
|
||||
setState(54)
|
||||
try match(ReferenceParser.Tokens.T__1.rawValue)
|
||||
setState(55)
|
||||
try namespace_component()
|
||||
|
||||
|
||||
setState(60)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class Namespace_componentContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_namespace_component
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterNamespace_component(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitNamespace_component(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func namespace_component() throws -> Namespace_componentContext {
|
||||
var _localctx: Namespace_componentContext
|
||||
_localctx = Namespace_componentContext(_ctx, getState())
|
||||
try enterRule(_localctx, 10, ReferenceParser.RULE_namespace_component)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(65)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
repeat {
|
||||
setState(61)
|
||||
try name()
|
||||
setState(63)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
if ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(62)
|
||||
try separator()
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
setState(67);
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
} while (_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class ReferenceContext: ParserRuleContext {
|
||||
open
|
||||
func tag() -> TagContext? {
|
||||
return getRuleContext(TagContext.self, 0)
|
||||
}
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_reference
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterReference(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitReference(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func reference() throws -> ReferenceContext {
|
||||
var _localctx: ReferenceContext
|
||||
_localctx = ReferenceContext(_ctx, getState())
|
||||
try enterRule(_localctx, 12, ReferenceParser.RULE_reference)
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
setState(76)
|
||||
try _errHandler.sync(self)
|
||||
switch (ReferenceParser.Tokens(rawValue: try _input.LA(1))!) {
|
||||
case .T__0:
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(69)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(70)
|
||||
try tag()
|
||||
|
||||
|
||||
break
|
||||
|
||||
case .T__4:
|
||||
try enterOuterAlt(_localctx, 2)
|
||||
setState(71)
|
||||
try match(ReferenceParser.Tokens.T__4.rawValue)
|
||||
setState(72)
|
||||
try name()
|
||||
setState(73)
|
||||
try match(ReferenceParser.Tokens.T__0.rawValue)
|
||||
setState(74)
|
||||
try name()
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class TagContext: ParserRuleContext {
|
||||
open
|
||||
func name() -> [NameContext] {
|
||||
return getRuleContexts(NameContext.self)
|
||||
}
|
||||
open
|
||||
func name(_ i: Int) -> NameContext? {
|
||||
return getRuleContext(NameContext.self, i)
|
||||
}
|
||||
open
|
||||
func separator() -> [SeparatorContext] {
|
||||
return getRuleContexts(SeparatorContext.self)
|
||||
}
|
||||
open
|
||||
func separator(_ i: Int) -> SeparatorContext? {
|
||||
return getRuleContext(SeparatorContext.self, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_tag
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterTag(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitTag(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func tag() throws -> TagContext {
|
||||
var _localctx: TagContext
|
||||
_localctx = TagContext(_ctx, getState())
|
||||
try enterRule(_localctx, 14, ReferenceParser.RULE_tag)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(78)
|
||||
try name()
|
||||
setState(84)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
while ((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0) {
|
||||
setState(79)
|
||||
try separator()
|
||||
setState(80)
|
||||
try name()
|
||||
|
||||
|
||||
setState(86)
|
||||
try _errHandler.sync(self)
|
||||
_la = try _input.LA(1)
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class SeparatorContext: ParserRuleContext {
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_separator
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterSeparator(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitSeparator(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func separator() throws -> SeparatorContext {
|
||||
var _localctx: SeparatorContext
|
||||
_localctx = SeparatorContext(_ctx, getState())
|
||||
try enterRule(_localctx, 16, ReferenceParser.RULE_separator)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(87)
|
||||
_la = try _input.LA(1)
|
||||
if (!((Int64(_la) & ~0x3f) == 0 && ((Int64(1) << _la) & 88) != 0)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
public class NameContext: ParserRuleContext {
|
||||
open
|
||||
func LETTER() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.LETTER.rawValue)
|
||||
}
|
||||
open
|
||||
func LETTER(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.LETTER.rawValue, i)
|
||||
}
|
||||
open
|
||||
func DIGIT() -> [TerminalNode] {
|
||||
return getTokens(ReferenceParser.Tokens.DIGIT.rawValue)
|
||||
}
|
||||
open
|
||||
func DIGIT(_ i:Int) -> TerminalNode? {
|
||||
return getToken(ReferenceParser.Tokens.DIGIT.rawValue, i)
|
||||
}
|
||||
override open
|
||||
func getRuleIndex() -> Int {
|
||||
return ReferenceParser.RULE_name
|
||||
}
|
||||
override open
|
||||
func enterRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.enterName(self)
|
||||
}
|
||||
}
|
||||
override open
|
||||
func exitRule(_ listener: ParseTreeListener) {
|
||||
if let listener = listener as? ReferenceListener {
|
||||
listener.exitName(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@discardableResult
|
||||
open func name() throws -> NameContext {
|
||||
var _localctx: NameContext
|
||||
_localctx = NameContext(_ctx, getState())
|
||||
try enterRule(_localctx, 18, ReferenceParser.RULE_name)
|
||||
var _la: Int = 0
|
||||
defer {
|
||||
try! exitRule()
|
||||
}
|
||||
do {
|
||||
var _alt:Int
|
||||
try enterOuterAlt(_localctx, 1)
|
||||
setState(90);
|
||||
try _errHandler.sync(self)
|
||||
_alt = 1;
|
||||
repeat {
|
||||
switch (_alt) {
|
||||
case 1:
|
||||
setState(89)
|
||||
_la = try _input.LA(1)
|
||||
if (!(_la == ReferenceParser.Tokens.DIGIT.rawValue || _la == ReferenceParser.Tokens.LETTER.rawValue)) {
|
||||
try _errHandler.recoverInline(self)
|
||||
}
|
||||
else {
|
||||
_errHandler.reportMatch(self)
|
||||
try consume()
|
||||
}
|
||||
|
||||
|
||||
break
|
||||
default:
|
||||
throw ANTLRException.recognition(e: NoViableAltException(self))
|
||||
}
|
||||
setState(92);
|
||||
try _errHandler.sync(self)
|
||||
_alt = try getInterpreter().adaptivePredict(_input,10,_ctx)
|
||||
} while (_alt != 2 && _alt != ATN.INVALID_ALT_NUMBER)
|
||||
|
||||
}
|
||||
catch ANTLRException.recognition(let re) {
|
||||
_localctx.exception = re
|
||||
_errHandler.reportError(self, re)
|
||||
try _errHandler.recover(self, re)
|
||||
}
|
||||
|
||||
return _localctx
|
||||
}
|
||||
|
||||
static let _serializedATN:[Int] = [
|
||||
4,1,8,95,2,0,7,0,2,1,7,1,2,2,7,2,2,3,7,3,2,4,7,4,2,5,7,5,2,6,7,6,2,7,7,
|
||||
7,2,8,7,8,2,9,7,9,1,0,1,0,1,0,3,0,24,8,0,1,0,1,0,1,0,3,0,29,8,0,1,0,1,
|
||||
0,1,1,1,1,1,1,5,1,36,8,1,10,1,12,1,39,9,1,1,2,4,2,42,8,2,11,2,12,2,43,
|
||||
1,3,1,3,1,3,5,3,49,8,3,10,3,12,3,52,9,3,1,4,1,4,1,4,5,4,57,8,4,10,4,12,
|
||||
4,60,9,4,1,5,1,5,3,5,64,8,5,4,5,66,8,5,11,5,12,5,67,1,6,1,6,1,6,1,6,1,
|
||||
6,1,6,1,6,3,6,77,8,6,1,7,1,7,1,7,1,7,5,7,83,8,7,10,7,12,7,86,9,7,1,8,1,
|
||||
8,1,9,4,9,91,8,9,11,9,12,9,92,1,9,0,0,10,0,2,4,6,8,10,12,14,16,18,0,2,
|
||||
2,0,3,4,6,6,1,0,7,8,95,0,20,1,0,0,0,2,32,1,0,0,0,4,41,1,0,0,0,6,45,1,0,
|
||||
0,0,8,53,1,0,0,0,10,65,1,0,0,0,12,76,1,0,0,0,14,78,1,0,0,0,16,87,1,0,0,
|
||||
0,18,90,1,0,0,0,20,23,3,2,1,0,21,22,5,1,0,0,22,24,3,4,2,0,23,21,1,0,0,
|
||||
0,23,24,1,0,0,0,24,25,1,0,0,0,25,26,5,2,0,0,26,28,3,8,4,0,27,29,3,12,6,
|
||||
0,28,27,1,0,0,0,28,29,1,0,0,0,29,30,1,0,0,0,30,31,5,0,0,1,31,1,1,0,0,0,
|
||||
32,37,3,6,3,0,33,34,5,3,0,0,34,36,3,6,3,0,35,33,1,0,0,0,36,39,1,0,0,0,
|
||||
37,35,1,0,0,0,37,38,1,0,0,0,38,3,1,0,0,0,39,37,1,0,0,0,40,42,5,7,0,0,41,
|
||||
40,1,0,0,0,42,43,1,0,0,0,43,41,1,0,0,0,43,44,1,0,0,0,44,5,1,0,0,0,45,50,
|
||||
3,18,9,0,46,47,5,4,0,0,47,49,3,18,9,0,48,46,1,0,0,0,49,52,1,0,0,0,50,48,
|
||||
1,0,0,0,50,51,1,0,0,0,51,7,1,0,0,0,52,50,1,0,0,0,53,58,3,10,5,0,54,55,
|
||||
5,2,0,0,55,57,3,10,5,0,56,54,1,0,0,0,57,60,1,0,0,0,58,56,1,0,0,0,58,59,
|
||||
1,0,0,0,59,9,1,0,0,0,60,58,1,0,0,0,61,63,3,18,9,0,62,64,3,16,8,0,63,62,
|
||||
1,0,0,0,63,64,1,0,0,0,64,66,1,0,0,0,65,61,1,0,0,0,66,67,1,0,0,0,67,65,
|
||||
1,0,0,0,67,68,1,0,0,0,68,11,1,0,0,0,69,70,5,1,0,0,70,77,3,14,7,0,71,72,
|
||||
5,5,0,0,72,73,3,18,9,0,73,74,5,1,0,0,74,75,3,18,9,0,75,77,1,0,0,0,76,69,
|
||||
1,0,0,0,76,71,1,0,0,0,77,13,1,0,0,0,78,84,3,18,9,0,79,80,3,16,8,0,80,81,
|
||||
3,18,9,0,81,83,1,0,0,0,82,79,1,0,0,0,83,86,1,0,0,0,84,82,1,0,0,0,84,85,
|
||||
1,0,0,0,85,15,1,0,0,0,86,84,1,0,0,0,87,88,7,0,0,0,88,17,1,0,0,0,89,91,
|
||||
7,1,0,0,90,89,1,0,0,0,91,92,1,0,0,0,92,90,1,0,0,0,92,93,1,0,0,0,93,19,
|
||||
1,0,0,0,11,23,28,37,43,50,58,63,67,76,84,92
|
||||
]
|
||||
|
||||
public
|
||||
static let _ATN = try! ATNDeserializer().deserialize(_serializedATN)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
all: clean
|
||||
antlr -o Generated -Dlanguage=Swift Reference.g4
|
||||
|
||||
clean:
|
||||
rm -rf Generated
|
||||
@@ -0,0 +1,14 @@
|
||||
grammar Reference;
|
||||
|
||||
root: host (':' port)? '/' namespace reference? EOF;
|
||||
host: host_component ('.' host_component)*;
|
||||
port: DIGIT+;
|
||||
host_component: name ('-' name)*;
|
||||
namespace: namespace_component ('/' namespace_component)*;
|
||||
namespace_component: (name separator?)+;
|
||||
reference: (':' tag) | ('@' name ':' name);
|
||||
tag: name (separator name)*;
|
||||
separator: '.' | '-' | '_';
|
||||
name: (LETTER | DIGIT)+;
|
||||
DIGIT: [0-9];
|
||||
LETTER: [A-Za-z];
|
||||
+161
-115
@@ -1,27 +1,42 @@
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import AsyncHTTPClient
|
||||
import NIOPosix
|
||||
import Algorithms
|
||||
import AsyncAlgorithms
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||
case MissingLocationHeader
|
||||
case AuthFailed(why: String, details: String = "")
|
||||
case MalformedHeader(why: String)
|
||||
}
|
||||
|
||||
extension HTTPClientResponse.Body {
|
||||
func readTextResponse() async throws -> String? {
|
||||
let data = try await readResponse()
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
enum HTTPMethod: String {
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
case PATCH = "PATCH"
|
||||
}
|
||||
|
||||
func readResponse() async throws -> Data {
|
||||
enum HTTPCode: Int {
|
||||
case Ok = 200
|
||||
case Created = 201
|
||||
case Accepted = 202
|
||||
case Unauthorized = 401
|
||||
}
|
||||
|
||||
extension Data {
|
||||
func asText() -> String {
|
||||
String(decoding: self, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
extension AsyncThrowingChannel<Data, Error> {
|
||||
func asData() async throws -> Data {
|
||||
var result = Data()
|
||||
for try await part in self {
|
||||
result.append(Data(buffer: part))
|
||||
|
||||
for try await chunk in self {
|
||||
result += chunk
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -77,81 +92,69 @@ struct TokenResponse: Decodable, Authentication {
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let httpClient = HTTPClient(
|
||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
||||
)
|
||||
|
||||
deinit {
|
||||
try! httpClient.syncShutdown()
|
||||
}
|
||||
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: Authentication? = nil
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
self.credentialsProvider = credentialsProvider
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
convenience init(
|
||||
host: String,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
var baseURLComponents = URLComponents()
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
baseURLComponents.scheme = "https"
|
||||
baseURLComponents.host = host
|
||||
baseURLComponents.path = "/v2/"
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
let response = try await endpointRequest(.GET, "/v2/")
|
||||
if response.status != .ok {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.status.code)
|
||||
let (_, response) = try await dataRequest(.GET, endpointURL("/v2/"))
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||
let manifestJSON = try manifest.toJSON()
|
||||
|
||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
||||
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
body: manifestJSON)
|
||||
if response.status != .created {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.status.code,
|
||||
details: try await response.body.readTextResponse() ?? "")
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
}
|
||||
|
||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||
let response = try await endpointRequest(.GET, "\(namespace)/manifests/\(reference)",
|
||||
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.status != .ok {
|
||||
let body = try await response.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.status.code,
|
||||
details: body ?? "")
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
let manifestData = try await response.body.readResponse()
|
||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
||||
let manifest = try OCIManifest(fromJSON: data)
|
||||
|
||||
return (manifest, manifestData)
|
||||
return (manifest, data)
|
||||
}
|
||||
|
||||
private func uploadLocationFromResponse(_ response: HTTPClientResponse) throws -> URLComponents {
|
||||
guard let uploadLocationRaw = response.headers.first(name: "Location") else {
|
||||
private func uploadLocationFromResponse(_ response: HTTPURLResponse) throws -> URLComponents {
|
||||
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
|
||||
throw RegistryError.MissingLocationHeader
|
||||
}
|
||||
|
||||
@@ -162,77 +165,109 @@ class Registry {
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSize: Int = 5 * 1024 * 1024) async throws -> String {
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
|
||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.status != .accepted {
|
||||
let body = try await postResponse.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.status.code,
|
||||
details: body ?? "")
|
||||
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: data.asText())
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
let uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
// Upload the blob
|
||||
let headers = [
|
||||
"Content-Length": "\(fromData.count)",
|
||||
"Content-Type": "application/octet-stream",
|
||||
]
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let parameters = [
|
||||
"digest": digest,
|
||||
]
|
||||
|
||||
let putResponse = try await rawRequest(.PUT, uploadLocation, headers: headers, parameters: parameters,
|
||||
body: fromData)
|
||||
if putResponse.status != .created {
|
||||
let body = try await postResponse.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: putResponse.status.code, details: body ?? "")
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
let (data, response) = try await dataRequest(
|
||||
.PUT,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
],
|
||||
parameters: ["digest": digest],
|
||||
body: fromData
|
||||
)
|
||||
if response.statusCode != HTTPCode.Created.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
// chunked upload
|
||||
var uploadedBytes = 0
|
||||
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
||||
for (index, chunk) in chunks.enumerated() {
|
||||
let lastChunk = index == (chunks.count - 1)
|
||||
let (data, response) = try await dataRequest(
|
||||
lastChunk ? .PUT : .PATCH,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
|
||||
],
|
||||
parameters: lastChunk ? ["digest": digest] : [:],
|
||||
body: chunk
|
||||
)
|
||||
// always accept both statuses since AWS ECR is not following specification
|
||||
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||
code: response.statusCode, details: data.asText())
|
||||
}
|
||||
uploadedBytes += chunk.count
|
||||
// Update location for the next chunk
|
||||
uploadLocation = try uploadLocationFromResponse(response)
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String, handler: (ByteBuffer) throws -> Void) async throws {
|
||||
let response = try await endpointRequest(.GET, "\(namespace)/blobs/\(digest)")
|
||||
if response.status != .ok {
|
||||
let body = try await response.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.status.code,
|
||||
details: body ?? "")
|
||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"))
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
let body = try await channel.asData().asText()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||
details: body)
|
||||
}
|
||||
|
||||
for try await part in response.body {
|
||||
for try await part in channel {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try handler(part)
|
||||
try handler(Data(part))
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointRequest(
|
||||
_ method: HTTPMethod,
|
||||
_ endpoint: String,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil
|
||||
) async throws -> HTTPClientResponse {
|
||||
private func endpointURL(_ endpoint: String) -> URLComponents {
|
||||
let url = URL(string: endpoint, relativeTo: baseURL)!
|
||||
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||
|
||||
return try await rawRequest(method, urlComponents, headers: headers, parameters: parameters, body: body)
|
||||
return URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
private func rawRequest(
|
||||
private func dataRequest(
|
||||
_ method: HTTPMethod,
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||
|
||||
return (try await channel.asData(), response)
|
||||
}
|
||||
|
||||
private func channelRequest(
|
||||
_ method: HTTPMethod,
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true
|
||||
) async throws -> HTTPClientResponse {
|
||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
@@ -242,13 +277,14 @@ class Registry {
|
||||
URLQueryItem(name: key, value: value)
|
||||
})
|
||||
|
||||
var request = HTTPClientRequest(url: urlComponents.string!)
|
||||
request.method = method
|
||||
var request = URLRequest(url: urlComponents.url!)
|
||||
request.httpMethod = method.rawValue
|
||||
for (key, value) in headers {
|
||||
request.headers.add(name: key, value: value)
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
if body != nil {
|
||||
request.body = HTTPClientRequest.Body.bytes(body!)
|
||||
if let body = body {
|
||||
request.addValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
@@ -256,26 +292,27 @@ class Registry {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var response = try await authAwareRequest(request: request)
|
||||
var (channel, response) = try await authAwareRequest(request: request)
|
||||
|
||||
if response.status == .unauthorized {
|
||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||
_ = try await channel.asData()
|
||||
try await auth(response: response)
|
||||
response = try await authAwareRequest(request: request)
|
||||
(channel, response) = try await authAwareRequest(request: request)
|
||||
}
|
||||
|
||||
return response
|
||||
return (channel, response)
|
||||
}
|
||||
|
||||
private func auth(response: HTTPClientResponse) async throws {
|
||||
private func auth(response: HTTPURLResponse) async throws {
|
||||
// Process WWW-Authenticate header
|
||||
guard let wwwAuthenticateRaw = response.headers.first(name: "WWW-Authenticate") else {
|
||||
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
||||
}
|
||||
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme == "Basic" {
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
@@ -312,30 +349,39 @@ class Registry {
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
|
||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.status != .ok {
|
||||
let body = try await response.body.readTextResponse() ?? ""
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
||||
+ "while retrieving an authentication token", details: body)
|
||||
let (data, response) = try await dataRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||
+ "while retrieving an authentication token", details: data.asText())
|
||||
}
|
||||
|
||||
let bodyData = try await response.body.readResponse()
|
||||
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
|
||||
currentAuthToken = try TokenResponse.parse(fromData: data)
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
let (name, value) = token.header()
|
||||
request.headers.add(name: name, value: value)
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
||||
let (channel, response) = try await Fetcher().fetch(request)
|
||||
|
||||
return (channel, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Parsing
|
||||
import Antlr4
|
||||
|
||||
struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||
enum ReferenceType: Comparable {
|
||||
@@ -46,6 +46,39 @@ struct Reference: Comparable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
}
|
||||
|
||||
class ReferenceCollector: ReferenceBaseListener {
|
||||
var host: String? = nil
|
||||
var port: String? = nil
|
||||
var namespace: String? = nil
|
||||
var reference: String? = nil
|
||||
|
||||
override func exitHost(_ ctx: ReferenceParser.HostContext) {
|
||||
host = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitPort(_ ctx: ReferenceParser.PortContext) {
|
||||
port = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
|
||||
namespace = ctx.getText()
|
||||
}
|
||||
|
||||
override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
|
||||
reference = ctx.getText()
|
||||
}
|
||||
}
|
||||
|
||||
class ErrorCollector: BaseErrorListener {
|
||||
var error: String? = nil
|
||||
|
||||
override func syntaxError<T>(_ recognizer: Recognizer<T>, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
|
||||
if error == nil {
|
||||
error = "\(msg) (character \(charPositionInLine + 1))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
var host: String
|
||||
var namespace: String
|
||||
@@ -58,51 +91,40 @@ struct RemoteName: Comparable, Hashable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
init(_ name: String) throws {
|
||||
let csNormal = [
|
||||
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
||||
UInt8(ascii: "A")...UInt8(ascii: "Z"),
|
||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
|
||||
let errorCollector = ErrorCollector()
|
||||
let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
|
||||
let lexer = ReferenceLexer(inputStream)
|
||||
lexer.removeErrorListeners()
|
||||
lexer.addErrorListener(errorCollector)
|
||||
|
||||
let csHex = [
|
||||
UInt8(ascii: "a")...UInt8(ascii: "f"),
|
||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
].asCharacterSet()
|
||||
let tokenStream = CommonTokenStream(lexer)
|
||||
let parser = try ReferenceParser(tokenStream)
|
||||
parser.removeErrorListeners()
|
||||
parser.addErrorListener(errorCollector)
|
||||
|
||||
let parser = Parse {
|
||||
Consumed {
|
||||
csNormal
|
||||
Optionally {
|
||||
":"
|
||||
Digits()
|
||||
}
|
||||
}
|
||||
"/"
|
||||
csNormal.union(CharacterSet(charactersIn: "/"))
|
||||
Optionally {
|
||||
OneOf {
|
||||
Parse {
|
||||
":"
|
||||
csNormal.map {
|
||||
Reference(tag: String($0))
|
||||
}
|
||||
}
|
||||
Parse {
|
||||
"@sha256:"
|
||||
csHex.map {
|
||||
Reference(digest: "sha256:" + String($0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
End()
|
||||
let referenceCollector = ReferenceCollector()
|
||||
try ParseTreeWalker().walk(referenceCollector, try parser.root())
|
||||
|
||||
if let error = errorCollector.error {
|
||||
throw RuntimeError("failed to parse remote name: \(error)")
|
||||
}
|
||||
|
||||
let result = try parser.parse(name)
|
||||
|
||||
host = String(result.0)
|
||||
namespace = String(result.1)
|
||||
reference = result.2 ?? Reference(tag: "latest")
|
||||
host = referenceCollector.host!
|
||||
if let port = referenceCollector.port {
|
||||
host += ":" + port
|
||||
}
|
||||
namespace = referenceCollector.namespace!
|
||||
if let reference = referenceCollector.reference {
|
||||
if reference.starts(with: "@sha256:") {
|
||||
self.reference = Reference(digest: String(reference.dropFirst(1)))
|
||||
} else if reference.starts(with: ":") {
|
||||
self.reference = Reference(tag: String(reference.dropFirst(1)))
|
||||
} else {
|
||||
throw RuntimeError("failed to parse remote name: unknown reference format")
|
||||
}
|
||||
} else {
|
||||
self.reference = Reference(tag: "latest")
|
||||
}
|
||||
}
|
||||
|
||||
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
||||
|
||||
@@ -24,7 +24,7 @@ let passphrases = [
|
||||
"act",
|
||||
"action",
|
||||
"actor",
|
||||
" actress",
|
||||
"actress",
|
||||
"actual",
|
||||
"adapt",
|
||||
"add",
|
||||
@@ -829,7 +829,7 @@ let passphrases = [
|
||||
"grow",
|
||||
"grunt",
|
||||
"guard",
|
||||
" guess",
|
||||
"guess",
|
||||
"guide",
|
||||
"guilt",
|
||||
"guitar",
|
||||
@@ -1476,7 +1476,7 @@ let passphrases = [
|
||||
"retire",
|
||||
"retreat",
|
||||
"return",
|
||||
" reunion",
|
||||
"reunion",
|
||||
"reveal",
|
||||
"review",
|
||||
"reward",
|
||||
@@ -1764,7 +1764,7 @@ let passphrases = [
|
||||
"swim",
|
||||
"swing",
|
||||
"switch",
|
||||
" sword",
|
||||
"sword",
|
||||
"symbol",
|
||||
"symptom",
|
||||
"syrup",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
|
||||
func CurrentArchitecture() -> Architecture {
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported starting with macOS Ventura
|
||||
// macOS Monterey will continue using a USB device == .darwin
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Virtualization
|
||||
|
||||
@available(macOS 13, *)
|
||||
struct Linux: Platform {
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Virtualization
|
||||
|
||||
enum OS: String, Codable {
|
||||
case darwin
|
||||
case linux
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
protocol PrunableStorage {
|
||||
func prunables() throws -> [Prunable]
|
||||
}
|
||||
|
||||
protocol Prunable {
|
||||
var url: URL { get }
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
func sizeBytes() throws -> Int
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Puppy
|
||||
|
||||
var puppy = Puppy.default
|
||||
|
||||
class LogFormatter: LogFormattable {
|
||||
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
|
||||
"\(date) \(level) \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
@@ -16,6 +25,8 @@ struct Root: AsyncParsableCommand {
|
||||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Prune.self,
|
||||
Rename.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
@@ -31,10 +42,22 @@ struct Root: AsyncParsableCommand {
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// Set line-buffered output for stdout
|
||||
setlinebuf(stdout)
|
||||
|
||||
// Initialize file logger
|
||||
let logFileURL = try Config().tartHomeDir.appendingPathComponent("tart.log")
|
||||
let fileLogger = try FileLogger("org.cirruslabs.tart", fileURL: logFileURL)
|
||||
fileLogger.format = LogFormatter()
|
||||
puppy.add(fileLogger)
|
||||
|
||||
// Parse and run command
|
||||
do {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
// Run garbage-collection before each command (shouldn't take too long)
|
||||
try Config().gc()
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func accessDate() throws -> Date {
|
||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||
return attrs.contentAccessDate!
|
||||
}
|
||||
|
||||
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||
let modificationDate = attrs.contentAccessDate!
|
||||
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
func asTimeval() -> timeval {
|
||||
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension URL: Prunable {
|
||||
var url: URL {
|
||||
self
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,20 @@ extension Collection {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBinaryPath(_ name: String) -> URL? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Dynamic
|
||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||
|
||||
extension VZVirtualMachine {
|
||||
@available(macOS 12, *)
|
||||
func start(_ recovery: Bool) async throws {
|
||||
if !recovery {
|
||||
// just use the regular API
|
||||
|
||||
+146
-102
@@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
import AsyncAlgorithms
|
||||
|
||||
struct UnsupportedRestoreImageError: Error {
|
||||
}
|
||||
@@ -10,6 +11,17 @@ struct NoMainScreenFoundError: Error {
|
||||
struct DownloadFailed: Error {
|
||||
}
|
||||
|
||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
|
||||
init(_ what: String, _ plural: String) {
|
||||
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
|
||||
}
|
||||
}
|
||||
|
||||
struct UnsupportedArchitectureError: Error {
|
||||
}
|
||||
|
||||
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
@@ -23,71 +35,87 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// VM's config
|
||||
var config: VMConfig
|
||||
|
||||
var softnet: Softnet? = nil
|
||||
var network: Network
|
||||
|
||||
init(vmDir: VMDirectory,
|
||||
withSoftnet: Bool = false,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directoryShares: [DirectoryShare] = []
|
||||
) throws {
|
||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
if config.arch != CurrentArchitecture() {
|
||||
throw UnsupportedArchitectureError()
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
// 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,
|
||||
directoryShares: directoryShares)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
virtualMachine.delegate = self
|
||||
}
|
||||
|
||||
static func retrieveLatestIPSW() async throws -> URL {
|
||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||
// Check if we already have this IPSW in cache
|
||||
let (channel, response) = try await Fetcher().fetch(URLRequest(url: remoteURL))
|
||||
|
||||
if let hash = (response as! HTTPURLResponse).value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||
|
||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||
try ipswLocation.updateAccessDate()
|
||||
|
||||
return ipswLocation
|
||||
}
|
||||
}
|
||||
|
||||
// Download the IPSW
|
||||
defaultLogger.appendNewLine("Fetching \(remoteURL.lastPathComponent)...")
|
||||
|
||||
let progress = Progress(totalUnitCount: response.expectedContentLength)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
let temporaryLocation = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString + ".ipsw")
|
||||
FileManager.default.createFile(atPath: temporaryLocation.path, contents: nil)
|
||||
let lock = try FileLock(lockURL: temporaryLocation)
|
||||
try lock.lock()
|
||||
|
||||
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
|
||||
let digest = Digest()
|
||||
|
||||
for try await chunk in channel {
|
||||
let chunkAsData = Data(chunk)
|
||||
fileHandle.write(chunkAsData)
|
||||
digest.update(chunkAsData)
|
||||
progress.completedUnitCount += Int64(chunk.count)
|
||||
}
|
||||
|
||||
try fileHandle.close()
|
||||
|
||||
let finalLocation = try IPSWCache().locationFor(fileName: digest.finalize() + ".ipsw")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let ipswCacheFolder = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
|
||||
|
||||
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||
return expectedIPSWLocation
|
||||
}
|
||||
|
||||
defaultLogger.appendNewLine("Fetching \(expectedIPSWLocation.lastPathComponent)...")
|
||||
|
||||
let data: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
let downloadedTask = URLSession.shared.dataTask(with: image.url) { data, response, error in
|
||||
if error != nil {
|
||||
continuation.resume(throwing: error!)
|
||||
return
|
||||
}
|
||||
if (data == nil) {
|
||||
continuation.resume(throwing: DownloadFailed())
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: data!)
|
||||
}
|
||||
ProgressObserver(downloadedTask.progress).log(defaultLogger)
|
||||
downloadedTask.resume()
|
||||
}
|
||||
|
||||
try data.write(to: expectedIPSWLocation, options: [.atomic])
|
||||
return expectedIPSWLocation
|
||||
return image.url
|
||||
}
|
||||
|
||||
|
||||
var inFinalState: Bool {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
@@ -99,12 +127,16 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL?,
|
||||
ipswURL: URL,
|
||||
diskSizeGB: UInt16,
|
||||
withSoftnet: Bool = false,
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
) async throws {
|
||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||
var ipswURL = ipswURL
|
||||
|
||||
if !ipswURL.isFileURL {
|
||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||
}
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
// that match both the image and our platform
|
||||
@@ -119,7 +151,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
@@ -127,7 +159,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
hardwareModel: requirements.hardwareModel,
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
@@ -136,12 +168,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
|
||||
self.network = network
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, network: network,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directoryShares: [])
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
@@ -149,7 +180,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Run automated installation
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
DispatchQueue.main.async {
|
||||
DispatchQueue.main.async { [ipswURL] in
|
||||
let installer = VZMacOSInstaller(virtualMachine: self.virtualMachine, restoringFromImageAt: ipswURL)
|
||||
|
||||
defaultLogger.appendNewLine("Installing OS...")
|
||||
@@ -162,12 +193,37 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
if let softnet = softnet {
|
||||
try softnet.run()
|
||||
}
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
// Create NVRAM
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
try await virtualMachine.start(recovery)
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
// Create config
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
try network.run(sema)
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
} else {
|
||||
// use method that also available on Monterey
|
||||
try await virtualMachine.start(recovery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await withTaskCancellationHandler(operation: {
|
||||
sema.wait()
|
||||
@@ -183,57 +239,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
if let softnet = softnet {
|
||||
try softnet.stop();
|
||||
}
|
||||
try await network.stop()
|
||||
}
|
||||
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
auxStorage: VZMacAuxiliaryStorage,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment]
|
||||
network: Network = NetworkShared(),
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directoryShares: [DirectoryShare]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
// Boot loader
|
||||
configuration.bootLoader = VZMacOSBootLoader()
|
||||
configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
|
||||
|
||||
// CPU and memory
|
||||
configuration.cpuCount = vmConfig.cpuCount
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
let platform = VZMacPlatformConfiguration()
|
||||
|
||||
platform.machineIdentifier = vmConfig.ecid
|
||||
platform.auxiliaryStorage = auxStorage
|
||||
platform.hardwareModel = vmConfig.hardwareModel
|
||||
|
||||
configuration.platform = platform
|
||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
|
||||
// Display
|
||||
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(
|
||||
width: vmConfig.display.width,
|
||||
height: vmConfig.display.height
|
||||
)
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
} else {
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
}
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
@@ -246,17 +276,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
|
||||
if let softnet = softnet {
|
||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
} else {
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
vio.attachment = network.attachment()
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
configuration.networkDevices = [vio]
|
||||
|
||||
@@ -268,6 +292,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Entropy
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
// Directory share
|
||||
if #available(macOS 13, *) {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
|
||||
configuration.directorySharingDevices = [sharingDevice]
|
||||
} else if !directoryShares.isEmpty {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
return configuration
|
||||
@@ -288,3 +326,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
sema.signal()
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
}
|
||||
|
||||
+30
-36
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case ecid
|
||||
case hardwareModel
|
||||
case os
|
||||
case arch
|
||||
case cpuCountMin
|
||||
case cpuCount
|
||||
case memorySizeMin
|
||||
case memorySize
|
||||
case macAddress
|
||||
case display
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable {
|
||||
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
var os: OS
|
||||
var arch: Architecture
|
||||
var platform: Platform
|
||||
var cpuCountMin: Int
|
||||
private(set) var cpuCount: Int
|
||||
var memorySizeMin: UInt64
|
||||
private(set) var memorySize: UInt64
|
||||
var macAddress: VZMACAddress
|
||||
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
|
||||
init(
|
||||
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
|
||||
hardwareModel: VZMacHardwareModel,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
self.platform = platform
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
version = try container.decode(Int.self, forKey: .version)
|
||||
|
||||
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")
|
||||
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
case .linux:
|
||||
if #available(macOS 13, *) {
|
||||
platform = try Linux(from: decoder)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
|
||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
|
||||
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(arch, forKey: .arch)
|
||||
try platform.encode(to: encoder)
|
||||
try container.encode(cpuCountMin, forKey: .cpuCountMin)
|
||||
try container.encode(cpuCount, forKey: .cpuCount)
|
||||
try container.encode(memorySizeMin, forKey: .memorySizeMin)
|
||||
|
||||
@@ -35,8 +35,8 @@ extension VMDirectory {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
let configFile = try FileHandle(forWritingTo: configURL)
|
||||
try await registry.pullBlob(configLayers.first!.digest) { buffer in
|
||||
configFile.write(Data(buffer: buffer))
|
||||
try await registry.pullBlob(configLayers.first!.digest) { data in
|
||||
configFile.write(data)
|
||||
}
|
||||
try configFile.close()
|
||||
|
||||
@@ -70,8 +70,7 @@ extension VMDirectory {
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
for diskLayer in diskLayers {
|
||||
try await registry.pullBlob(diskLayer.digest) { buffer in
|
||||
let data = Data(buffer: buffer)
|
||||
try await registry.pullBlob(diskLayer.digest) { data in
|
||||
try filter.write(data)
|
||||
progress.completedUnitCount += Int64(data.count)
|
||||
}
|
||||
@@ -92,19 +91,20 @@ extension VMDirectory {
|
||||
throw OCIError.FailedToCreateVmFile
|
||||
}
|
||||
let nvram = try FileHandle(forWritingTo: nvramURL)
|
||||
try await registry.pullBlob(nvramLayers.first!.digest) { buffer in
|
||||
nvram.write(Data(buffer: buffer))
|
||||
try await registry.pullBlob(nvramLayers.first!.digest) { data in
|
||||
nvram.write(data)
|
||||
}
|
||||
try nvram.close()
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String]) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
let configJSON = try JSONEncoder().encode(config)
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON)
|
||||
defaultLogger.appendNewLine("pushing config...")
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
||||
|
||||
// Progress
|
||||
@@ -116,35 +116,37 @@ extension VMDirectory {
|
||||
|
||||
// Read VM's compressed disk as chunks
|
||||
// and sequentially upload them as blobs
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
var diskReadBytes: UInt64 = 0
|
||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
||||
diskReadBytes += UInt64(data?.count ?? 0)
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDiskSize = mappedDisk.count
|
||||
var mappedDiskReadOffset = 0
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
|
||||
return data
|
||||
}
|
||||
while let chunk = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let chunkDigest = try await registry.pushBlob(fromData: chunk)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: chunk.count, digest: chunkDigest))
|
||||
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
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
|
||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||
let nvramDigest = try await registry.pushBlob(fromData: nvram)
|
||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
let ociConfigJSON = try OCIConfig().toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers,
|
||||
uncompressedDiskSize: diskReadBytes
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
)
|
||||
|
||||
// Manifest
|
||||
|
||||
@@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
|
||||
struct AlreadyInitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct VMDirectory {
|
||||
struct VMDirectory: Prunable {
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -20,12 +20,20 @@ struct VMDirectory {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
}
|
||||
|
||||
var name: String {
|
||||
baseURL.lastPathComponent
|
||||
}
|
||||
|
||||
var url: URL {
|
||||
baseURL
|
||||
}
|
||||
|
||||
static func temporary() throws -> VMDirectory {
|
||||
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
|
||||
|
||||
return VMDirectory(baseURL: tmpDir)
|
||||
@@ -77,4 +85,24 @@ struct VMDirectory {
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
func isExplicitlyPulled() -> Bool {
|
||||
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
@@ -32,6 +32,10 @@ class VMStorageLocal {
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
|
||||
}
|
||||
|
||||
func rename(_ name: String, _ newName: String) throws {
|
||||
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
+117
-10
@@ -1,12 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageOCI {
|
||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
}
|
||||
|
||||
private func hostDirectoryURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingHost(name)
|
||||
}
|
||||
|
||||
func exists(_ name: RemoteName) -> Bool {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
@@ -16,6 +20,8 @@ class VMStorageOCI {
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -40,10 +46,48 @@ class VMStorageOCI {
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try gc()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
var result: [(String, VMDirectory)] = Array()
|
||||
func gc() throws {
|
||||
var refCounts = Dictionary<URL, UInt>()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let foundURL as URL in enumerator {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
|
||||
// Perform garbage collection for tag-based images
|
||||
// with broken outgoing references
|
||||
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
|
||||
try FileManager.default.removeItem(at: foundURL)
|
||||
continue
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
|
||||
}
|
||||
|
||||
// Perform garbage collection for digest-based images
|
||||
// with no incoming references
|
||||
for (baseURL, incRefCount) in refCounts {
|
||||
let vmDir = VMDirectory(baseURL: baseURL)
|
||||
|
||||
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory, Bool)] {
|
||||
var result: [(String, VMDirectory, Bool)] = Array()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
@@ -60,28 +104,81 @@ class VMStorageOCI {
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
if isSymlink {
|
||||
name = parts.joined(separator: ":")
|
||||
} else {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
result.append((name, vmDir))
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
|
||||
var digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: try manifest.digest()))
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
|
||||
|
||||
// Acquire a lock on it to prevent concurrent pulls for a single host
|
||||
let lock = try FileLock(lockURL: hostDirectoryURL)
|
||||
|
||||
let sucessfullyLocked = try lock.trylock()
|
||||
if !sucessfullyLocked {
|
||||
print("waiting for lock...")
|
||||
try lock.lock()
|
||||
}
|
||||
defer { try! lock.unlock() }
|
||||
|
||||
if Task.isCancelled {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
if !exists(digestName) {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Lock the temporary VM directory to prevent it's garbage collection
|
||||
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
|
||||
try tmpVMDirLock.lock()
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||
|
||||
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 {
|
||||
puppy.warning("important capacity \(capacityImportant) bytes, "
|
||||
+ "available capacity is \(capacityAvailable) bytes")
|
||||
}
|
||||
|
||||
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||
// Let's validate to avoid unnecessary pruning.
|
||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
|
||||
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
|
||||
+ "is \(requiredCapacityBytes) bytes)")
|
||||
|
||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||
}
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
@@ -93,8 +190,12 @@ class VMStorageOCI {
|
||||
}
|
||||
|
||||
if name != digestName {
|
||||
// Overwrite the old symbolic link
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +205,8 @@ class VMStorageOCI {
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
|
||||
try gc()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,4 +220,8 @@ extension URL {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func appendingHost(_ name: RemoteName) -> URL {
|
||||
self.appendingPathComponent(name.host, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC {
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class FileLockTests: XCTestCase {
|
||||
func testSimple() throws {
|
||||
// Create a temporary file that will be used as a lock
|
||||
let url = temporaryFile()
|
||||
|
||||
// Make sure this file can be locked and unlocked
|
||||
let lock = try FileLock(lockURL: url)
|
||||
try lock.lock()
|
||||
try lock.unlock()
|
||||
}
|
||||
|
||||
func testDoubleLockResultsInError() throws {
|
||||
// Create a temporary file that will be used as a lock
|
||||
let url = temporaryFile()
|
||||
|
||||
// Create two locks on a same file and ensure one of them fails
|
||||
let firstLock = try FileLock(lockURL: url)
|
||||
try firstLock.lock()
|
||||
|
||||
let secondLock = try! FileLock(lockURL: url)
|
||||
XCTAssertFalse(try secondLock.trylock())
|
||||
}
|
||||
|
||||
private func temporaryFile() -> URL {
|
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
||||
|
||||
FileManager.default.createFile(atPath: url.path, contents: nil)
|
||||
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -34,26 +34,26 @@ final class RegistryTests: XCTestCase {
|
||||
|
||||
// Pull it
|
||||
var pulledBlob = Data()
|
||||
try await registry.pullBlob(pushedBlobDigest) { buffer in
|
||||
pulledBlob.append(Data(buffer: buffer))
|
||||
try await registry.pullBlob(pushedBlobDigest) { data in
|
||||
pulledBlob.append(data)
|
||||
}
|
||||
|
||||
// Ensure that both blobs are identical
|
||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||
}
|
||||
|
||||
func testPushPullBlobHuge() async throws {
|
||||
func testPushPullBlobHugeInChunks() async throws {
|
||||
// Generate a large enough blob
|
||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
||||
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
|
||||
|
||||
// Push it
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush)
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||
|
||||
// Pull it
|
||||
var pulledLargeBlob = Data()
|
||||
try await registry.pullBlob(largeBlobDigest) { buffer in
|
||||
pulledLargeBlob.append(Data(buffer: buffer))
|
||||
try await registry.pullBlob(largeBlobDigest) { data in
|
||||
pulledLargeBlob.append(data)
|
||||
}
|
||||
|
||||
// Ensure that both blobs are identical
|
||||
|
||||
@@ -44,4 +44,8 @@ final class RemoteNameTests: XCTestCase {
|
||||
// Port must be specified when ":" is used
|
||||
XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil)
|
||||
}
|
||||
|
||||
func testNoPathTraversal() throws {
|
||||
XCTAssertEqual(try? RemoteName("ghcr.io/a/../b/c:latest"), nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class URLAccessDateTests: XCTestCase {
|
||||
func testGetAndSetAccessTime() throws {
|
||||
// Create a temporary file
|
||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
||||
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
|
||||
|
||||
// Ensure it's access date is different than our desired access date
|
||||
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
|
||||
|
||||
// Set our desired access date for a file
|
||||
try tmpFile.updateAccessDate(arbitraryDate)
|
||||
|
||||
// Ensure the access date has changed to our value
|
||||
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
|
||||
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
source = [".build/arm64-apple-macosx/debug/tart"]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
username = "hello@cirruslabs.org"
|
||||
password = "@env:AC_PASSWORD"
|
||||
}
|
||||
|
||||
sign {
|
||||
application_identity = "Developer ID Application: Cirrus Labs, Inc."
|
||||
entitlements_file = "Resources/tart.entitlements"
|
||||
}
|
||||
Reference in New Issue
Block a user