├── .ci
├── create-pkg.sh
├── pkg
│ └── scripts
│ │ └── postinstall
└── set-version.sh
├── .cirrus.yml
├── .editorconfig
├── .github
├── CODEOWNERS
└── FUNDING.yml
├── .gitignore
├── .goreleaser.yml
├── .swiftformat
├── CONTRIBUTING.md
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
├── Resources
├── AppIcon.png
├── CirrusRunnersForGHA.png
├── TartScreenshot.png
├── TartSocial.png
├── Users
│ ├── Background.png
│ ├── CirrusCI.png
│ ├── Codemagic.png
│ ├── HowToAddYourself.md
│ ├── Krisp.png
│ ├── Mullvad.png
│ ├── PITSGlobalDataRecoveryServices.png
│ ├── Suran.png
│ ├── Symflower.png
│ ├── TestingBot.png
│ ├── Transloadit.png
│ └── ahrefs.png
├── embedded.provisionprofile
├── tart-dev.entitlements
└── tart-prod.entitlements
├── Sources
└── tart
│ ├── CI
│ └── CI.swift
│ ├── Commands
│ ├── Clone.swift
│ ├── Create.swift
│ ├── Delete.swift
│ ├── Export.swift
│ ├── Get.swift
│ ├── IP.swift
│ ├── Import.swift
│ ├── List.swift
│ ├── Login.swift
│ ├── Logout.swift
│ ├── Prune.swift
│ ├── Pull.swift
│ ├── Push.swift
│ ├── Rename.swift
│ ├── Run.swift
│ ├── Set.swift
│ ├── Stop.swift
│ └── Suspend.swift
│ ├── Config.swift
│ ├── Credentials
│ ├── CredentialsProvider.swift
│ ├── DockerConfigCredentialsProvider.swift
│ ├── EnvironmentCredentialsProvider.swift
│ ├── KeychainCredentialsProvider.swift
│ └── StdinCredentials.swift
│ ├── DeviceInfo
│ └── DeviceInfo.swift
│ ├── Embed.swift
│ ├── Fetcher.swift
│ ├── FileLock.swift
│ ├── Formatter
│ └── Format.swift
│ ├── IPSWCache.swift
│ ├── Logging
│ ├── Logger.swift
│ ├── ProgressObserver.swift
│ └── URLSessionLogger.swift
│ ├── MACAddressResolver
│ ├── ARPCache.swift
│ ├── Lease.swift
│ ├── Leases.swift
│ └── MACAddress.swift
│ ├── Network
│ ├── Network.swift
│ ├── NetworkBridged.swift
│ ├── NetworkShared.swift
│ └── Softnet.swift
│ ├── OCI
│ ├── Authentication.swift
│ ├── Digest.swift
│ ├── Manifest.swift
│ ├── Reference
│ │ ├── Generated
│ │ │ ├── Reference.interp
│ │ │ ├── Reference.tokens
│ │ │ ├── ReferenceBaseListener.swift
│ │ │ ├── ReferenceLexer.interp
│ │ │ ├── ReferenceLexer.swift
│ │ │ ├── ReferenceLexer.tokens
│ │ │ ├── ReferenceListener.swift
│ │ │ └── ReferenceParser.swift
│ │ ├── Makefile
│ │ └── Reference.g4
│ ├── Registry.swift
│ ├── RemoteName.swift
│ ├── URL+Absolutize.swift
│ └── WWWAuthenticate.swift
│ ├── PIDLock.swift
│ ├── Passphrase
│ ├── PassphraseGenerator.swift
│ └── Words.swift
│ ├── Platform
│ ├── Architecture.swift
│ ├── Darwin.swift
│ ├── Linux.swift
│ ├── OS.swift
│ └── Platform.swift
│ ├── Prunable.swift
│ ├── Root.swift
│ ├── Serial.swift
│ ├── URL+AccessDate.swift
│ ├── URL+Prunable.swift
│ ├── Utils.swift
│ ├── VM+Recovery.swift
│ ├── VM.swift
│ ├── VMConfig.swift
│ ├── VMDirectory+Archive.swift
│ ├── VMDirectory+OCI.swift
│ ├── VMDirectory.swift
│ ├── VMStartOptions.swift
│ ├── VMStorageHelper.swift
│ ├── VMStorageLocal.swift
│ ├── VMStorageOCI.swift
│ └── VNC
│ ├── FullFledgedVNC.swift
│ ├── ScreenSharingVNC.swift
│ └── VNC.swift
├── Tests
└── TartTests
│ ├── DigestTests.swift
│ ├── DirecotryShareTests.swift
│ ├── FileLockTests.swift
│ ├── MACAddressResolverTests.swift
│ ├── RegistryTests.swift
│ ├── RemoteNameTests.swift
│ ├── TokenResponseTests.swift
│ ├── URLAbsolutizationTests.swift
│ ├── URLAccessDateTests.swift
│ ├── Util
│ └── RegistryRunner.swift
│ └── WWWAuthenticateTests.swift
├── docs
├── CNAME
├── assets
│ ├── TartLicenseSubscription.pdf
│ ├── animations
│ │ ├── Orchard.lottie
│ │ └── TartLogo.lottie
│ └── images
│ │ ├── CirrusLogo.svg
│ │ ├── TartCirrusCLI.gif
│ │ ├── TartGHARunners.png
│ │ ├── TartLogo.png
│ │ ├── favicon.ico
│ │ ├── orchard-port-forwarding-api.png
│ │ ├── spotlight
│ │ ├── github-actions-runners.webp
│ │ ├── supported-registries.webp
│ │ └── virtualization-framework.webp
│ │ └── users
│ │ ├── max-lapides.webp
│ │ ├── mikhail-tokarev.webp
│ │ └── seb-jachec.webp
├── blog
│ ├── .authors.yml
│ ├── index.md
│ └── posts
│ │ ├── 2023-02-11-changing-tart-license.md
│ │ ├── 2023-04-25-orchard-ga.md
│ │ └── 2023-04-28-orchard-ssh-over-grpc.md
├── faq.md
├── index.md
├── integrations
│ ├── cirrus-cli.md
│ ├── github-actions.md
│ ├── gitlab-runner.md
│ └── vm-management.md
├── legal
│ ├── privacy.md
│ └── terms.md
├── licensing.md
├── quick-start.md
├── robots.txt
├── stylesheets
│ ├── extra.css
│ └── landing.css
└── theme
│ └── overrides
│ └── home.html
├── gon.hcl
├── integration-tests
├── conftest.py
├── docker_registry.py
├── requirements.txt
├── tart.py
├── test_clone.py
├── test_create.py
├── test_delete.py
├── test_oci.py
└── test_rename.py
├── mkdocs.yml
└── scripts
└── run-signed.sh
/.ci/create-pkg.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | export VERSION="${CIRRUS_TAG:-0}"
6 |
7 | mkdir -p .ci/pkg/
8 | cp .build/arm64-apple-macosx/release/tart .ci/pkg/tart
9 | cp Resources/embedded.provisionprofile .ci/pkg/embedded.provisionprofile
10 | pkgbuild --root .ci/pkg/ --identifier com.github.cirruslabs.tart --version $VERSION \
11 | --scripts .ci/pkg/scripts \
12 | --install-location "/Library/Application Support/Tart" \
13 | --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" \
14 | "./.ci/Tart-$VERSION.pkg"
15 | xcrun notarytool submit "./.ci/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
16 | xcrun stapler staple "./.ci/Tart-$VERSION.pkg"
17 |
--------------------------------------------------------------------------------
/.ci/pkg/scripts/postinstall:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | # fix structure
6 | mkdir -p "$2/tart.app/Contents/MacOS"
7 | mv "$2/tart" "$2/tart.app/Contents/MacOS/tart"
8 | mv "$2/embedded.provisionprofile" "$2/tart.app/Contents/embedded.provisionprofile"
9 |
10 | echo "#!/bin/sh" > /usr/local/bin/tart
11 | echo "exec '$2/tart.app/Contents/MacOS/tart' \"\$@\"" >> /usr/local/bin/tart
12 |
13 | chmod +x /usr/local/bin/tart
14 |
--------------------------------------------------------------------------------
/.ci/set-version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | TMPFILE=$(mktemp)
4 | envsubst < Sources/tart/CI/CI.swift > $TMPFILE
5 | mv $TMPFILE Sources/tart/CI/CI.swift
6 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | insert_final_newline = true
7 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @edigaryev @fkorotkov
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [cirruslabs]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode's user settings
2 | xcuserdata/
3 | tart.xcodeproj/
4 |
5 | # SwiftPM
6 | .swiftpm/
7 |
8 | # AppCode
9 | .idea/
10 |
11 | # Swift
12 | .build/
13 |
14 | # GoReleaser
15 | dist/
16 |
17 | # mkdocs
18 | .cache
19 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | project_name: tart
2 |
3 | before:
4 | hooks:
5 | - .ci/set-version.sh
6 | - swift build -c release --product tart
7 | - gon gon.hcl
8 | - mkdir -p tart.app/Contents/MacOS
9 | - cp .build/arm64-apple-macosx/release/tart tart.app/Contents/MacOS/
10 |
11 | builds:
12 | - builder: prebuilt
13 | goos:
14 | - darwin
15 | goarch:
16 | - arm64
17 | binary: tart.app/Contents/MacOS/tart
18 | prebuilt:
19 | path: tart.app/Contents/MacOS/tart
20 |
21 | archives:
22 | - name_template: "{{ .ProjectName }}"
23 | files:
24 | - src: Resources/embedded.provisionprofile
25 | dst: tart.app/Contents
26 | strip_parent: true
27 | - LICENSE
28 |
29 | release:
30 | prerelease: auto
31 |
32 | brews:
33 | - name: tart
34 | tap:
35 | owner: cirruslabs
36 | name: homebrew-cli
37 | caveats: See the GitHub repository for more information
38 | homepage: https://github.com/cirruslabs/tart
39 | license: "Fair Source"
40 | description: Run macOS VMs on Apple Silicon
41 | skip_upload: auto
42 | dependencies:
43 | - "cirruslabs/cli/softnet"
44 | install: |
45 | libexec.install Dir["*"]
46 | bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
47 | custom_block: |
48 | depends_on :macos => :monterey
49 |
50 | on_macos do
51 | unless Hardware::CPU.arm?
52 | odie "Tart only works on Apple Silicon!"
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/.swiftformat:
--------------------------------------------------------------------------------
1 | --disable all
2 | --enable indent
3 | --indent 2
4 | --exclude Sources/tart/OCI/Reference/Generated
5 | --swiftversion 5.7
6 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Tart
2 |
3 | Table of Contents
4 | -----------------
5 |
6 | - [How to Build](#how-to-build)
7 | - [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
8 | - [Style Guidelines](#style-guidelines)
9 | - [Pull Requests](#Pull-Requests)
10 |
11 | ## How to Build
12 |
13 | 1. Fork the repository to your own GitHub account
14 | 2. Clone the forked repository to your local machine
15 | 3. If using Xcode, use from Xcode 15 or newer
16 | 4. Run ./scripts/run-signed.sh from the root of your repository
17 |
18 | ```bash
19 | ./scripts/run-signed.sh list
20 | ```
21 | ## How to Create an Issue/Enhancement
22 |
23 | 1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
24 | 2. Click on the "New Issue" button
25 | 3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
26 | 4. Submit the issue
27 |
28 | ## Style Guidelines
29 |
30 | 1. Code should follow camel case
31 | 2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
32 | ```bash
33 | swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
34 | ```
35 |
36 | ## Pull Requests
37 |
38 | 1. Provide a detailed description of the changes you made in the pull request
39 | 2. Wait for pull request to be reviewed
40 | 3. Make adjustments if necessary
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Fair Source License, version 0.9
2 |
3 | Copyright (C) 2023 Cirrus Labs, Inc.
4 |
5 | Licensor: Cirrus Labs, Inc.
6 |
7 | Software: Tart
8 |
9 | Use Limitation: 100 users. User is defined as a single core of a central processing unit (CPU) used by the product.
10 | The Use Limitation does not apply to CPUs installed in devices used by a single individual.
11 |
12 | License Grant. Licensor hereby grants to each recipient of the
13 | Software ("you") a non-exclusive, non-transferable, royalty-free and
14 | fully-paid-up license, under all of the Licensor's copyright and
15 | patent rights, to use, copy, distribute, prepare derivative works of,
16 | publicly perform and display the Software, subject to the Use
17 | Limitation and the conditions set forth below.
18 |
19 | Use Limitation. The license granted above allows use by up to the
20 | number of users per entity set forth above (the "Use Limitation"). For
21 | determining the number of users, "you" includes all affiliates,
22 | meaning legal entities controlling, controlled by, or under common
23 | control with you. If you exceed the Use Limitation, your use is
24 | subject to payment of Licensor's then-current list price for licenses.
25 |
26 | Conditions. Redistribution in source code or other forms must include
27 | a copy of this license document to be provided in a reasonable
28 | manner. Any redistribution of the Software is only allowed subject to
29 | this license.
30 |
31 | Trademarks. This license does not grant you any right in the
32 | trademarks, service marks, brand names or logos of Licensor.
33 |
34 | DISCLAIMER. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OR
35 | CONDITION, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES
36 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
37 | NONINFRINGEMENT. LICENSORS HEREBY DISCLAIM ALL LIABILITY, WHETHER IN
38 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
39 | CONNECTION WITH THE SOFTWARE.
40 |
41 | Termination. If you violate the terms of this license, your rights
42 | will terminate automatically and will not be reinstated without the
43 | prior written consent of Licensor. Any such termination will not
44 | affect the right of others who may have received copies of the
45 | Software from you.
46 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "antlr4",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/antlr/antlr4",
7 | "state" : {
8 | "branch" : "dev",
9 | "revision" : "2703a8516c0fb7fe92db6b9c40e0113f577646d2"
10 | }
11 | },
12 | {
13 | "identity" : "dynamic",
14 | "kind" : "remoteSourceControl",
15 | "location" : "https://github.com/mhdhejazi/Dynamic",
16 | "state" : {
17 | "branch" : "master",
18 | "revision" : "772883073d044bc754d401cabb6574624eb3778f"
19 | }
20 | },
21 | {
22 | "identity" : "sentry-cocoa",
23 | "kind" : "remoteSourceControl",
24 | "location" : "https://github.com/getsentry/sentry-cocoa",
25 | "state" : {
26 | "revision" : "d277532e1c8af813981ba01f591b15bbdd735615",
27 | "version" : "8.8.0"
28 | }
29 | },
30 | {
31 | "identity" : "swift-algorithms",
32 | "kind" : "remoteSourceControl",
33 | "location" : "https://github.com/apple/swift-algorithms",
34 | "state" : {
35 | "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
36 | "version" : "1.0.0"
37 | }
38 | },
39 | {
40 | "identity" : "swift-argument-parser",
41 | "kind" : "remoteSourceControl",
42 | "location" : "https://github.com/apple/swift-argument-parser",
43 | "state" : {
44 | "revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
45 | "version" : "1.1.2"
46 | }
47 | },
48 | {
49 | "identity" : "swift-async-algorithms",
50 | "kind" : "remoteSourceControl",
51 | "location" : "https://github.com/apple/swift-async-algorithms",
52 | "state" : {
53 | "branch" : "main",
54 | "revision" : "f05e450f0b909c0e80670a47516c4b9700b9e5da"
55 | }
56 | },
57 | {
58 | "identity" : "swift-atomics",
59 | "kind" : "remoteSourceControl",
60 | "location" : "https://github.com/apple/swift-atomics.git",
61 | "state" : {
62 | "revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
63 | "version" : "1.0.2"
64 | }
65 | },
66 | {
67 | "identity" : "swift-collections",
68 | "kind" : "remoteSourceControl",
69 | "location" : "https://github.com/apple/swift-collections.git",
70 | "state" : {
71 | "revision" : "f504716c27d2e5d4144fa4794b12129301d17729",
72 | "version" : "1.0.3"
73 | }
74 | },
75 | {
76 | "identity" : "swift-numerics",
77 | "kind" : "remoteSourceControl",
78 | "location" : "https://github.com/apple/swift-numerics",
79 | "state" : {
80 | "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
81 | "version" : "1.0.2"
82 | }
83 | },
84 | {
85 | "identity" : "swift-sysctl",
86 | "kind" : "remoteSourceControl",
87 | "location" : "https://github.com/sersoft-gmbh/swift-sysctl.git",
88 | "state" : {
89 | "revision" : "71fd64ee84819bb19fbecfb36d5a4503726b6fb7",
90 | "version" : "1.6.0"
91 | }
92 | },
93 | {
94 | "identity" : "swiftdate",
95 | "kind" : "remoteSourceControl",
96 | "location" : "https://github.com/malcommac/SwiftDate",
97 | "state" : {
98 | "revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
99 | "version" : "6.3.1"
100 | }
101 | },
102 | {
103 | "identity" : "swiftformat",
104 | "kind" : "remoteSourceControl",
105 | "location" : "https://github.com/nicklockwood/SwiftFormat",
106 | "state" : {
107 | "revision" : "da637c398c5d08896521b737f2868ddc2e7996ae",
108 | "version" : "0.50.6"
109 | }
110 | },
111 | {
112 | "identity" : "texttable",
113 | "kind" : "remoteSourceControl",
114 | "location" : "https://github.com/cfilipov/TextTable",
115 | "state" : {
116 | "branch" : "master",
117 | "revision" : "e03289289155b4e7aa565e32862f9cb42140596a"
118 | }
119 | }
120 | ],
121 | "version" : 2
122 | }
123 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.7
2 |
3 | import PackageDescription
4 | let package = Package(
5 | name: "Tart",
6 | platforms: [
7 | .macOS(.v12)
8 | ],
9 | products: [
10 | .executable(name: "tart", targets: ["tart"])
11 | ],
12 | dependencies: [
13 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
14 | .package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
15 | .package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
16 | .package(url: "https://github.com/apple/swift-async-algorithms", branch: "main"),
17 | .package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1"),
18 | .package(url: "https://github.com/antlr/antlr4", branch: "dev"),
19 | .package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
20 | .package(url: "https://github.com/nicklockwood/SwiftFormat", from: "0.50.6"),
21 | .package(url: "https://github.com/getsentry/sentry-cocoa", from: "8.8.0"),
22 | .package(url: "https://github.com/cfilipov/TextTable", branch: "master"),
23 | .package(url: "https://github.com/sersoft-gmbh/swift-sysctl.git", from: "1.0.0"),
24 | ],
25 | targets: [
26 | .executableTarget(name: "tart", dependencies: [
27 | .product(name: "Algorithms", package: "swift-algorithms"),
28 | .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
29 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
30 | .product(name: "Dynamic", package: "Dynamic"),
31 | .product(name: "SwiftDate", package: "SwiftDate"),
32 | .product(name: "Antlr4Static", package: "Antlr4"),
33 | .product(name: "Atomics", package: "swift-atomics"),
34 | .product(name: "Sentry", package: "sentry-cocoa"),
35 | .product(name: "TextTable", package: "TextTable"),
36 | .product(name: "Sysctl", package: "swift-sysctl"),
37 | ], exclude: [
38 | "OCI/Reference/Makefile",
39 | "OCI/Reference/Reference.g4",
40 | "OCI/Reference/Generated/Reference.interp",
41 | "OCI/Reference/Generated/Reference.tokens",
42 | "OCI/Reference/Generated/ReferenceLexer.interp",
43 | "OCI/Reference/Generated/ReferenceLexer.tokens",
44 | ]),
45 | .testTarget(name: "TartTests", dependencies: ["tart"])
46 | ]
47 | )
48 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | *Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
4 | Built by CI engineers for your automation needs. Here are some highlights of Tart:
5 |
6 | * Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
7 | * Push/Pull virtual machines from any OCI-compatible container registry.
8 | * Use Tart Packer Plugin to automate VM creation.
9 | * Easily integrates with any CI system.
10 |
11 | Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
12 | service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | Tart is also adopted by several other automation services:
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | Many more companies are using Tart in their internal setups. Here are a few of them:
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | **Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
61 |
62 | ## Usage
63 |
64 | Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
65 |
66 | ```bash
67 | brew install cirruslabs/cli/tart
68 | tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
69 | tart run ventura-base
70 | ```
71 |
72 | Please check the [official documentation](https://tart.run) for more information and/or feel free to use [discussions](https://github.com/cirruslabs/tart/discussions)
73 | for remaining questions.
74 |
--------------------------------------------------------------------------------
/Resources/AppIcon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/AppIcon.png
--------------------------------------------------------------------------------
/Resources/CirrusRunnersForGHA.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/CirrusRunnersForGHA.png
--------------------------------------------------------------------------------
/Resources/TartScreenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/TartScreenshot.png
--------------------------------------------------------------------------------
/Resources/TartSocial.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/TartSocial.png
--------------------------------------------------------------------------------
/Resources/Users/Background.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Background.png
--------------------------------------------------------------------------------
/Resources/Users/CirrusCI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/CirrusCI.png
--------------------------------------------------------------------------------
/Resources/Users/Codemagic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Codemagic.png
--------------------------------------------------------------------------------
/Resources/Users/HowToAddYourself.md:
--------------------------------------------------------------------------------
1 | If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
2 | that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
3 |
4 | You can refer to `Background.png` as a base for your logo.
5 |
--------------------------------------------------------------------------------
/Resources/Users/Krisp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Krisp.png
--------------------------------------------------------------------------------
/Resources/Users/Mullvad.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Mullvad.png
--------------------------------------------------------------------------------
/Resources/Users/PITSGlobalDataRecoveryServices.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/PITSGlobalDataRecoveryServices.png
--------------------------------------------------------------------------------
/Resources/Users/Suran.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Suran.png
--------------------------------------------------------------------------------
/Resources/Users/Symflower.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Symflower.png
--------------------------------------------------------------------------------
/Resources/Users/TestingBot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/TestingBot.png
--------------------------------------------------------------------------------
/Resources/Users/Transloadit.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/Transloadit.png
--------------------------------------------------------------------------------
/Resources/Users/ahrefs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/Users/ahrefs.png
--------------------------------------------------------------------------------
/Resources/embedded.provisionprofile:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/Resources/embedded.provisionprofile
--------------------------------------------------------------------------------
/Resources/tart-dev.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.virtualization
6 |
7 | com.apple.private.virtualization
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/Resources/tart-prod.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.virtualization
6 |
7 | com.apple.vm.networking
8 |
9 | com.apple.private.virtualization
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/Sources/tart/CI/CI.swift:
--------------------------------------------------------------------------------
1 | struct CI {
2 | private static let rawVersion = "${CIRRUS_TAG}"
3 |
4 | static var version: String {
5 | rawVersion.expanded() ? rawVersion : "SNAPSHOT"
6 | }
7 |
8 | static var release: String? {
9 | rawVersion.expanded() ? "tart@\(rawVersion)" : nil
10 | }
11 | }
12 |
13 | private extension String {
14 | func expanded() -> Bool {
15 | !isEmpty && !starts(with: "$")
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Clone.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import SystemConfiguration
4 |
5 | struct Clone: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(
7 | abstract: "Clone a VM",
8 | discussion: """
9 | Creates a local virtual machine by cloning either a remote or another local virtual machine.
10 |
11 | Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
12 | Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
13 | in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
14 | will be modified.
15 |
16 | This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
17 | for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
18 | """
19 | )
20 |
21 | @Argument(help: "source VM name")
22 | var sourceName: String
23 |
24 | @Argument(help: "new VM name")
25 | var newName: String
26 |
27 | @Flag(help: "connect to the OCI registry via insecure HTTP protocol")
28 | var insecure: Bool = false
29 |
30 | func validate() throws {
31 | if newName.contains("/") {
32 | throw ValidationError(" should be a local name")
33 | }
34 | }
35 |
36 | func run() async throws {
37 | let ociStorage = VMStorageOCI()
38 | let localStorage = VMStorageLocal()
39 |
40 | if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
41 | // Pull the VM in case it's OCI-based and doesn't exist locally yet
42 | let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
43 | try await ociStorage.pull(remoteName, registry: registry)
44 | }
45 |
46 | let sourceVM = try VMStorageHelper.open(sourceName)
47 | let tmpVMDir = try VMDirectory.temporary()
48 |
49 | // Lock the temporary VM directory to prevent it's garbage collection
50 | let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
51 | try tmpVMDirLock.lock()
52 |
53 | try await withTaskCancellationHandler(operation: {
54 | // Acquire a global lock
55 | let lock = try FileLock(lockURL: Config().tartHomeDir)
56 | try lock.lock()
57 |
58 | let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
59 | && sourceVM.state() != "suspended"
60 | try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
61 |
62 | try localStorage.move(newName, from: tmpVMDir)
63 |
64 | try lock.unlock()
65 |
66 | // APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
67 | // is not actually claiming new space until the VM is started and it writes something to disk.
68 | // So once we clone the VM let's try to claim a little bit of space for the VM to run.
69 | try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
70 | }, onCancel: {
71 | try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
72 | })
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Create.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 | import Foundation
5 |
6 | struct Create: AsyncParsableCommand {
7 | static var configuration = CommandConfiguration(abstract: "Create a VM")
8 |
9 | @Argument(help: "VM name")
10 | var name: String
11 |
12 | @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"))
13 | var fromIPSW: String?
14 |
15 | @Flag(help: "create a Linux VM")
16 | var linux: Bool = false
17 |
18 | @Option(help: ArgumentHelp("Disk size in Gb"))
19 | var diskSize: UInt16 = 50
20 |
21 | @Option(help: ArgumentHelp("Path to custom ROM image (AVPBooter)"))
22 | var romPath: String = "/System/Library/Frameworks/Virtualization.framework/Versions/A/Resources/AVPBooter.vmapple2.bin";
23 |
24 | func validate() throws {
25 | if fromIPSW == nil && !linux {
26 | throw ValidationError("Please specify either a --from-ipsw or --linux option!")
27 | }
28 | }
29 |
30 | func run() async throws {
31 | let tmpVMDir = try VMDirectory.temporary()
32 |
33 | // Lock the temporary VM directory to prevent it's garbage collection
34 | let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
35 | try tmpVMDirLock.lock()
36 |
37 | try await withTaskCancellationHandler(operation: {
38 | if let fromIPSW = fromIPSW {
39 | let ipswURL: URL
40 |
41 | if fromIPSW == "latest" {
42 | ipswURL = try await VM.latestIPSWURL()
43 | } else if fromIPSW.starts(with: "http://") || fromIPSW.starts(with: "https://") {
44 | ipswURL = URL(string: fromIPSW)!
45 | } else {
46 | ipswURL = URL(fileURLWithPath: fromIPSW)
47 | }
48 |
49 | let romURL = URL(fileURLWithPath: romPath)
50 | print("romURL: \(romURL)")
51 |
52 | _ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, romURL: romURL)
53 | }
54 |
55 | if linux {
56 | if #available(macOS 13, *) {
57 | _ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
58 | } else {
59 | throw UnsupportedOSError("Linux VMs", "are")
60 | }
61 | }
62 |
63 | try VMStorageLocal().move(name, from: tmpVMDir)
64 | }, onCancel: {
65 | try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
66 | })
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Delete.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 |
5 | struct Delete: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(abstract: "Delete a VM")
7 |
8 | @Argument(help: "VM name")
9 | var name: [String]
10 |
11 | func run() async throws {
12 | for it in name {
13 | try VMStorageHelper.delete(it)
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Export.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | struct Export: AsyncParsableCommand {
5 | static var configuration = CommandConfiguration(abstract: "Export VM to a compressed .tvm file")
6 |
7 | @Argument(help: "Source VM name.")
8 | var name: String
9 |
10 | @Argument(help: "Path to the destination file.")
11 | var path: String?
12 |
13 | func run() async throws {
14 | let correctedPath: String
15 |
16 | if let path = path {
17 | correctedPath = path
18 | } else {
19 | correctedPath = "\(name).tvm"
20 |
21 | if FileManager.default.fileExists(atPath: correctedPath) {
22 | while true {
23 | if userWantsOverwrite(correctedPath) {
24 | break
25 | } else {
26 | return
27 | }
28 | }
29 | }
30 | }
31 |
32 | print("exporting...")
33 |
34 | try VMStorageHelper.open(name).exportToArchive(path: correctedPath)
35 | }
36 |
37 | func userWantsOverwrite(_ filename: String) -> Bool {
38 | print("file \(filename) already exists, are you sure you want to overwrite it? (yes, [no])? ", terminator: "")
39 |
40 | let answer = readLine()!
41 |
42 | return answer == "yes"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Get.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | fileprivate struct VMInfo: Encodable {
5 | let CPU: Int
6 | let Memory: UInt64
7 | let Disk: Int
8 | let Display: String
9 | let Running: Bool
10 | let State: String
11 | }
12 |
13 | struct Get: AsyncParsableCommand {
14 | static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
15 |
16 | @Argument(help: "VM name.")
17 | var name: String
18 |
19 | @Option(help: "Output format: text or json")
20 | var format: Format = .text
21 |
22 | func run() async throws {
23 | let vmDir = try VMStorageLocal().open(name)
24 | let vmConfig = try VMConfig(fromURL: vmDir.configURL)
25 | let diskSizeInGb = try vmDir.sizeGB()
26 | let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
27 |
28 | let info = VMInfo(CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: diskSizeInGb,
29 | Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state())
30 | print(format.renderSingle(info))
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/IP.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import Network
4 | import SystemConfiguration
5 | import Sentry
6 |
7 | enum IPResolutionStrategy: String, ExpressibleByArgument, CaseIterable {
8 | case dhcp, arp
9 |
10 | private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
11 | }
12 |
13 | struct IP: AsyncParsableCommand {
14 | static var configuration = CommandConfiguration(abstract: "Get VM's IP address")
15 |
16 | @Argument(help: "VM name")
17 | var name: String
18 |
19 | @Option(help: "Number of seconds to wait for a potential VM booting")
20 | var wait: UInt16 = 0
21 |
22 | @Option(help: ArgumentHelp("Strategy for resolving IP address: dhcp or arp",
23 | discussion: """
24 | By default, Tart is looking up and parsing DHCP lease file to determine the IP of the VM.\n
25 | This method is fast and the most reliable but only returns local IP adresses.\n
26 | Alternatively, Tart can call external `arp` executable and parse it's output.\n
27 | In case of enabled Bridged Networking this method will return VM's IP address on the network interface used for Bridged Networking.\n
28 | Note that `arp` strategy won't work for VMs using `--net-softnet`.
29 | """))
30 | var resolver: IPResolutionStrategy = .dhcp
31 |
32 | func run() async throws {
33 | let vmDir = try VMStorageLocal().open(name)
34 | let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
35 | let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
36 |
37 | guard let ip = try await IP.resolveIP(vmMACAddress, resolutionStrategy: resolver, secondsToWait: wait) else {
38 | var message = "no IP address found"
39 |
40 | if try !vmDir.running() {
41 | message += ", is your VM running?"
42 | }
43 |
44 | if (vmConfig.os == .linux && resolver == .arp) {
45 | message += " (not all Linux distributions are compatible with the ARP resolver)"
46 | }
47 |
48 | throw RuntimeError.NoIPAddressFound(message)
49 | }
50 |
51 | print(ip)
52 | }
53 |
54 | static public func resolveIP(_ vmMACAddress: MACAddress, resolutionStrategy: IPResolutionStrategy = .dhcp, secondsToWait: UInt16 = 0) async throws -> IPv4Address? {
55 | let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
56 |
57 | repeat {
58 | switch resolutionStrategy {
59 | case .arp:
60 | if let ip = try ARPCache().ResolveMACAddress(macAddress: vmMACAddress) {
61 | return ip
62 | }
63 | case .dhcp:
64 | if let leases = try Leases(), let ip = try leases.ResolveMACAddress(macAddress: vmMACAddress) {
65 | return ip
66 | }
67 | }
68 |
69 | // wait a second
70 | try await Task.sleep(nanoseconds: 1_000_000_000)
71 | } while Date.now < waitUntil
72 |
73 | return nil
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Import.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | struct Import: AsyncParsableCommand {
5 | static var configuration = CommandConfiguration(abstract: "Import VM from a compressed .tvm file")
6 |
7 | @Argument(help: "Path to a file created with \"tart export\".")
8 | var path: String
9 |
10 | @Argument(help: "Destination VM name.")
11 | var name: String
12 |
13 | func validate() throws {
14 | if name.contains("/") {
15 | throw ValidationError(" should be a local name")
16 | }
17 | }
18 |
19 | func run() async throws {
20 | let localStorage = VMStorageLocal()
21 |
22 | // Create a temporary VM directory to which we will load the export file
23 | let tmpVMDir = try VMDirectory.temporary()
24 |
25 | // Lock the temporary VM directory to prevent it's garbage collection
26 | // while we're running
27 | let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
28 | try tmpVMDirLock.lock()
29 |
30 | // Populate the temporary VM directory with the export file contents
31 | print("importing...")
32 | try tmpVMDir.importFromArchive(path: path)
33 |
34 | try await withTaskCancellationHandler(operation: {
35 | // Acquire a global lock
36 | let lock = try FileLock(lockURL: Config().tartHomeDir)
37 | try lock.lock()
38 |
39 | // Re-generate the VM's MAC address importing it will result in address collision
40 | if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) {
41 | try tmpVMDir.regenerateMACAddress()
42 | }
43 |
44 | try localStorage.move(name, from: tmpVMDir)
45 |
46 | try lock.unlock()
47 | }, onCancel: {
48 | try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/List.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 |
5 | fileprivate struct VMInfo: Encodable {
6 | let Source: String
7 | let Name: String
8 | let Size: Int
9 | let Running: Bool
10 | let State: String
11 | }
12 |
13 | struct List: AsyncParsableCommand {
14 | static var configuration = CommandConfiguration(abstract: "List created VMs")
15 |
16 | @Option(help: ArgumentHelp("Only display VMs from the specified source (e.g. --source local, --source oci)."))
17 | var source: String?
18 |
19 | @Option(help: "Output format: text or json")
20 | var format: Format = .text
21 |
22 | @Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names."))
23 | var quiet: Bool = false
24 |
25 | func validate() throws {
26 | guard let source = source else {
27 | return
28 | }
29 |
30 | if !["local", "oci"].contains(source) {
31 | throw ValidationError("'\(source)' is not a valid ")
32 | }
33 | }
34 |
35 | func run() async throws {
36 | var infos: [VMInfo] = []
37 |
38 | if source == nil || source == "local" {
39 | infos += sortedInfos(try VMStorageLocal().list().map { (name, vmDir) in
40 | try VMInfo(Source: "local", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
41 | })
42 | }
43 |
44 | if source == nil || source == "oci" {
45 | infos += sortedInfos(try VMStorageOCI().list().map { (name, vmDir, _) in
46 | try VMInfo(Source: "oci", Name: name, Size: vmDir.sizeGB(), Running: vmDir.running(), State: vmDir.state())
47 | })
48 | }
49 |
50 | if (quiet) {
51 | for info in infos {
52 | print(info.Name)
53 | }
54 | } else {
55 | print(format.renderList(infos))
56 | }
57 | }
58 |
59 | private func sortedInfos(_ infos: [VMInfo]) -> [VMInfo] {
60 | infos.sorted(by: { left, right in left.Name < right.Name })
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Login.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 |
5 | struct Login: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(abstract: "Login to a registry")
7 |
8 | @Argument(help: "host")
9 | var host: String
10 |
11 | @Option(help: "username")
12 | var username: String?
13 |
14 | @Flag(help: "password-stdin")
15 | var passwordStdin: Bool = false
16 |
17 | @Flag(help: "connect to the OCI registry via insecure HTTP protocol")
18 | var insecure: Bool = false
19 |
20 | @Flag(help: "skip validation of the registry's credentials before logging-in")
21 | var noValidate: Bool = false
22 |
23 | func validate() throws {
24 | let usernameProvided = username != nil
25 | let passwordProvided = passwordStdin
26 |
27 | if usernameProvided != passwordProvided {
28 | throw ValidationError("both --username and --password-stdin are required")
29 | }
30 | }
31 |
32 | func run() async throws {
33 | var user: String
34 | var password: String
35 |
36 | if let username = username {
37 | user = username
38 |
39 | let passwordData = FileHandle.standardInput.readDataToEndOfFile()
40 | password = String(decoding: passwordData, as: UTF8.self)
41 |
42 | // Support "echo $PASSWORD | tart login --username $USERNAME --password-stdin $REGISTRY"
43 | password.trimSuffix { c in c.isNewline }
44 | } else {
45 | (user, password) = try StdinCredentials.retrieve()
46 | }
47 | let credentialsProvider = DictionaryCredentialsProvider([
48 | host: (user, password)
49 | ])
50 |
51 | if !noValidate {
52 | do {
53 | let registry = try Registry(host: host, namespace: "", insecure: insecure,
54 | credentialsProviders: [credentialsProvider])
55 | try await registry.ping()
56 | } catch {
57 | throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
58 | }
59 | }
60 |
61 | try KeychainCredentialsProvider().store(host: host, user: user, password: password)
62 | }
63 | }
64 |
65 | fileprivate class DictionaryCredentialsProvider: CredentialsProvider {
66 | var credentials: Dictionary
67 |
68 | init(_ credentials: Dictionary) {
69 | self.credentials = credentials
70 | }
71 |
72 | func retrieve(host: String) throws -> (String, String)? {
73 | credentials[host]
74 | }
75 |
76 | func store(host: String, user: String, password: String) throws {
77 | credentials[host] = (user, password)
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Logout.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 |
5 | struct Logout: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(abstract: "Logout from a registry")
7 |
8 | @Argument(help: "host")
9 | var host: String
10 |
11 | func run() async throws {
12 | try KeychainCredentialsProvider().remove(host: host)
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Pull.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import SwiftUI
4 |
5 | struct Pull: AsyncParsableCommand {
6 | static var configuration = CommandConfiguration(
7 | abstract: "Pull a VM from a registry",
8 | discussion: """
9 | Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
10 | Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
11 |
12 | By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
13 | This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
14 | """
15 | )
16 |
17 | @Argument(help: "remote VM name")
18 | var remoteName: String
19 |
20 | @Flag(help: "connect to the OCI registry via insecure HTTP protocol")
21 | var insecure: Bool = false
22 |
23 | func run() async throws {
24 | // Be more liberal when accepting local image as argument,
25 | // see https://github.com/cirruslabs/tart/issues/36
26 | if VMStorageLocal().exists(remoteName) {
27 | print("\"\(remoteName)\" is a local image, nothing to pull here!")
28 |
29 | return
30 | }
31 |
32 | let remoteName = try RemoteName(remoteName)
33 | let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
34 |
35 | defaultLogger.appendNewLine("pulling \(remoteName)...")
36 |
37 | try await VMStorageOCI().pull(remoteName, registry: registry)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Push.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Dispatch
3 | import Foundation
4 | import Compression
5 |
6 | struct Push: AsyncParsableCommand {
7 | static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
8 |
9 | @Argument(help: "local or remote VM name")
10 | var localName: String
11 |
12 | @Argument(help: "remote VM name(s)")
13 | var remoteNames: [String]
14 |
15 | @Flag(help: "connect to the OCI registry via insecure HTTP protocol")
16 | var insecure: Bool = false
17 |
18 | @Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
19 | discussion: """
20 | By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
21 | 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.
22 | 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.
23 | """))
24 | var chunkSize: Int = 0
25 |
26 | @Flag(help: ArgumentHelp("cache pushed images locally",
27 | discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
28 | var populateCache: Bool = false
29 |
30 | func run() async throws {
31 | let ociStorage = VMStorageOCI()
32 | let localVMDir = try VMStorageHelper.open(localName)
33 |
34 | // Parse remote names supplied by the user
35 | let remoteNames = try remoteNames.map{
36 | try RemoteName($0)
37 | }
38 |
39 | // Group remote names by registry
40 | struct RegistryIdentifier: Hashable, Equatable {
41 | var host: String
42 | var namespace: String
43 | }
44 |
45 | let registryGroups = Dictionary(grouping: remoteNames, by: {
46 | RegistryIdentifier(host: $0.host, namespace: $0.namespace)
47 | })
48 |
49 | // Push VM
50 | for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
51 | let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
52 | insecure: insecure)
53 |
54 | defaultLogger.appendNewLine("pushing \(localName) to "
55 | + "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
56 |
57 | let references = remoteNamesForRegistry.map{ $0.reference.value }
58 |
59 | let pushedRemoteName: RemoteName
60 | // If we're pushing a local OCI VM, check if points to an already existing registry manifest
61 | // and if so, only upload manifests (without config, disk and NVRAM) to the user-specified references
62 | if let remoteName = try? RemoteName(localName) {
63 | pushedRemoteName = try await lightweightPushToRegistry(
64 | registry: registry,
65 | remoteName: remoteName,
66 | references: references
67 | )
68 | } else {
69 | pushedRemoteName = try await localVMDir.pushToRegistry(
70 | registry: registry,
71 | references: references,
72 | chunkSizeMb: chunkSize
73 | )
74 | // Populate the local cache (if requested)
75 | if populateCache {
76 | let expectedPushedVMDir = try ociStorage.create(pushedRemoteName)
77 | try localVMDir.clone(to: expectedPushedVMDir, generateMAC: false)
78 | }
79 | }
80 |
81 | // link the rest remote names
82 | if populateCache {
83 | for remoteName in remoteNamesForRegistry {
84 | try ociStorage.link(from: remoteName, to: pushedRemoteName)
85 | }
86 | }
87 | }
88 | }
89 |
90 | func lightweightPushToRegistry(registry: Registry, remoteName: RemoteName, references: [String]) async throws -> RemoteName {
91 | // Is the local OCI VM already present in the registry?
92 | let digest = try VMStorageOCI().digest(remoteName)
93 |
94 | let (remoteManifest, _) = try await registry.pullManifest(reference: digest)
95 |
96 | // Overwrite registry's references with the retrieved manifest
97 | for reference in references {
98 | defaultLogger.appendNewLine("pushing manifest for \(reference)...")
99 |
100 | _ = try await registry.pushManifest(reference: reference, manifest: remoteManifest)
101 | }
102 |
103 | return RemoteName(host: registry.host!, namespace: registry.namespace,
104 | reference: Reference(digest: digest))
105 | }
106 | }
107 |
108 | extension Collection where Element == RemoteName {
109 | func referenceNames() -> String {
110 | let references = self.map{ $0.reference.fullyQualified }
111 |
112 | switch count {
113 | case 0: return "∅"
114 | case 1: return references.first!
115 | default: return "{" + references.joined(separator: ",") + "}"
116 | }
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Rename.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | struct Rename: AsyncParsableCommand {
5 | static var configuration = CommandConfiguration(abstract: "Rename a VM")
6 |
7 | @Argument(help: "VM name")
8 | var name: String
9 |
10 | @Argument(help: "new VM name")
11 | var newName: String
12 |
13 | func validate() throws {
14 | if newName.contains("/") {
15 | throw ValidationError(" should be a local name")
16 | }
17 | }
18 |
19 | func run() async throws {
20 | let localStorage = VMStorageLocal()
21 |
22 | if !localStorage.exists(name) {
23 | throw ValidationError("failed to rename a non-existent VM: \(name)")
24 | }
25 |
26 | if localStorage.exists(newName) {
27 | throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
28 | }
29 |
30 | try localStorage.rename(name, newName)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Set.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 |
4 | struct Set: AsyncParsableCommand {
5 | static var configuration = CommandConfiguration(commandName: "set", abstract: "Modify VM's configuration")
6 |
7 | @Argument(help: "VM name")
8 | var name: String
9 |
10 | @Option(help: "Number of VM CPUs")
11 | var cpu: UInt16?
12 |
13 | @Option(help: "VM memory size in megabytes")
14 | var memory: UInt64?
15 |
16 | @Option(help: "VM display resolution in a format of x. For example, 1200x800")
17 | var display: VMDisplayConfig?
18 |
19 | @Option(help: .hidden)
20 | var diskSize: UInt16?
21 |
22 | func run() async throws {
23 | let vmDir = try VMStorageLocal().open(name)
24 | var vmConfig = try VMConfig(fromURL: vmDir.configURL)
25 |
26 | if let cpu = cpu {
27 | try vmConfig.setCPU(cpuCount: Int(cpu))
28 | }
29 |
30 | if let memory = memory {
31 | try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
32 | }
33 |
34 | if let display = display {
35 | if (display.width > 0) {
36 | vmConfig.display.width = display.width
37 | }
38 | if (display.height > 0) {
39 | vmConfig.display.height = display.height
40 | }
41 | }
42 |
43 | try vmConfig.save(toURL: vmDir.configURL)
44 |
45 | if diskSize != nil {
46 | try vmDir.resizeDisk(diskSize!)
47 | }
48 | }
49 | }
50 |
51 | extension VMDisplayConfig: ExpressibleByArgument {
52 | public init(argument: String) {
53 | let parts = argument.components(separatedBy: "x").map {
54 | Int($0) ?? 0
55 | }
56 | self = VMDisplayConfig(
57 | width: parts[safe: 0] ?? 0,
58 | height: parts[safe: 1] ?? 0
59 | )
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Stop.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import System
4 | import SwiftDate
5 |
6 | struct Stop: AsyncParsableCommand {
7 | static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
8 |
9 | @Argument(help: "VM name")
10 | var name: String
11 |
12 | @Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
13 | var timeout: UInt64 = 30
14 |
15 | func run() async throws {
16 | let vmDir = try VMStorageLocal().open(name)
17 | switch try vmDir.state() {
18 | case "suspended":
19 | try stopSuspended(vmDir)
20 | case "running":
21 | try await stopRunning(vmDir)
22 | default:
23 | return
24 | }
25 | }
26 |
27 | func stopSuspended(_ vmDir: VMDirectory) throws {
28 | try? FileManager.default.removeItem(at: vmDir.stateURL)
29 | }
30 |
31 | func stopRunning(_ vmDir: VMDirectory) async throws {
32 | let lock = try PIDLock(lockURL: vmDir.configURL)
33 |
34 | // Find the VM's PID
35 | var pid = try lock.pid()
36 | if pid == 0 {
37 | throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
38 | }
39 |
40 | // Try to gracefully terminate the VM
41 | //
42 | // Note that we don't check the return code here
43 | // to provide a clean exit from "tart stop" in cases
44 | // when the VM is already shutting down and we hit
45 | // a race condition.
46 | //
47 | // We check the return code in the kill(2) below, though,
48 | // because it's a less common scenario and it would be
49 | // nice to know for the user that we've tried all methods
50 | // and failed to shutdown the VM.
51 | kill(pid, SIGINT)
52 |
53 | // Ensure that the VM has terminated
54 | var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
55 | let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
56 |
57 | while gracefulWaitDuration.value > 0 {
58 | pid = try lock.pid()
59 | if pid == 0 {
60 | return
61 | }
62 |
63 | try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
64 | gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
65 | }
66 |
67 | // Seems that VM is still running, proceed with forceful termination
68 | let ret = kill(pid, SIGKILL)
69 | if ret != 0 {
70 | let details = Errno(rawValue: CInt(errno))
71 |
72 | throw RuntimeError.VMTerminationFailed("failed to forcefully terminate the VM \"\(name)\": \(details)")
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/tart/Commands/Suspend.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import System
4 | import SwiftDate
5 |
6 | struct Suspend: AsyncParsableCommand {
7 | static var configuration = CommandConfiguration(commandName: "suspend", abstract: "Suspend a VM")
8 |
9 | @Argument(help: "VM name")
10 | var name: String
11 |
12 | func run() async throws {
13 | let vmDir = try VMStorageLocal().open(name)
14 | let lock = try PIDLock(lockURL: vmDir.configURL)
15 |
16 | // Find the VM's PID
17 | var pid = try lock.pid()
18 | if pid == 0 {
19 | throw RuntimeError.VMNotRunning("VM \"\(name)\" is not running")
20 | }
21 |
22 | // Tell the "tart run" process to suspend the VM
23 | let ret = kill(pid, SIGUSR1)
24 | if ret != 0 {
25 | throw RuntimeError.SuspendFailed("failed to send SIGUSR1 signal to the \"tart run\" process running VM \"\(name)\"")
26 | }
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/tart/Config.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct Config {
4 | let tartHomeDir: URL
5 | let tartCacheDir: URL
6 | let tartTmpDir: URL
7 |
8 | init() throws {
9 | var tartHomeDir: URL
10 |
11 | if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
12 | tartHomeDir = URL(fileURLWithPath: customTartHome)
13 | } else {
14 | tartHomeDir = FileManager.default
15 | .homeDirectoryForCurrentUser
16 | .appendingPathComponent(".tart", isDirectory: true)
17 | }
18 | self.tartHomeDir = tartHomeDir
19 |
20 | tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
21 | try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
22 |
23 | tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
24 | try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
25 | }
26 |
27 | func gc() throws {
28 | for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
29 | includingPropertiesForKeys: [], options: []) {
30 | let lock = try FileLock(lockURL: entry)
31 | if try !lock.trylock() {
32 | continue
33 | }
34 |
35 | try FileManager.default.removeItem(at: entry)
36 |
37 | try lock.unlock()
38 | }
39 | }
40 |
41 | static func jsonEncoder() -> JSONEncoder {
42 | let encoder = JSONEncoder()
43 |
44 | encoder.outputFormatting = [.sortedKeys]
45 |
46 | return encoder
47 | }
48 |
49 | static func jsonDecoder() -> JSONDecoder {
50 | JSONDecoder()
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/tart/Credentials/CredentialsProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum CredentialsProviderError: Error {
4 | case Failed(message: String)
5 | }
6 |
7 | protocol CredentialsProvider {
8 | func retrieve(host: String) throws -> (String, String)?
9 | func store(host: String, user: String, password: String) throws
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/tart/Credentials/DockerConfigCredentialsProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class DockerConfigCredentialsProvider: CredentialsProvider {
4 | func retrieve(host: String) throws -> (String, String)? {
5 | let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
6 | if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
7 | return nil
8 | }
9 | let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
10 |
11 | if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
12 | return credentialsFromAuth
13 | }
14 | if let helperProgram = config.credHelpers?[host] {
15 | return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
16 | }
17 |
18 | return nil
19 | }
20 |
21 | private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
22 | guard let executableURL = resolveBinaryPath(binaryName) else {
23 | throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
24 | }
25 |
26 | let process = Process.init()
27 | process.executableURL = executableURL
28 | process.arguments = ["get"]
29 |
30 | let outPipe = Pipe()
31 | let inPipe = Pipe()
32 |
33 | process.standardOutput = outPipe
34 | process.standardError = outPipe
35 | process.standardInput = inPipe
36 |
37 | process.launch()
38 |
39 | inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
40 | inPipe.fileHandleForWriting.closeFile()
41 |
42 | process.waitUntilExit()
43 |
44 | if !(process.terminationReason == .exit && process.terminationStatus == 0) {
45 | throw CredentialsProviderError.Failed(message: "Docker helper failed!")
46 | }
47 |
48 | let getOutput = try JSONDecoder().decode(
49 | DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
50 | )
51 | return (getOutput.Username, getOutput.Secret)
52 | }
53 |
54 | func store(host: String, user: String, password: String) throws {
55 | throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
56 | }
57 | }
58 |
59 | struct DockerConfig: Codable {
60 | var auths: Dictionary? = Dictionary()
61 | var credHelpers: Dictionary? = Dictionary()
62 | }
63 |
64 | struct DockerAuthConfig: Codable {
65 | var auth: String? = nil
66 |
67 | func decodeCredentials() -> (String, String)? {
68 | // auth is a base64("username:password")
69 | guard let authBase64 = auth else {
70 | return nil
71 | }
72 | guard let data = Data(base64Encoded: authBase64) else {
73 | return nil
74 | }
75 | guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
76 | return nil
77 | }
78 | if components.count != 2 {
79 | return nil
80 | }
81 | return (components[0], components[1])
82 | }
83 | }
84 |
85 | struct DockerGetOutput: Codable {
86 | var Username: String
87 | var Secret: String
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/tart/Credentials/EnvironmentCredentialsProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class EnvironmentCredentialsProvider: CredentialsProvider {
4 | func retrieve(host: String) throws -> (String, String)? {
5 | if let tartRegistryHostname = ProcessInfo.processInfo.environment["TART_REGISTRY_HOSTNAME"],
6 | tartRegistryHostname != host {
7 | return nil
8 | }
9 |
10 | let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
11 | let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
12 | if let username = username, let password = password {
13 | return (username, password)
14 | }
15 | return nil
16 | }
17 |
18 | func store(host: String, user: String, password: String) throws {
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/tart/Credentials/KeychainCredentialsProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class KeychainCredentialsProvider: CredentialsProvider {
4 | func retrieve(host: String) throws -> (String, String)? {
5 | let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
6 | kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
7 | kSecAttrServer as String: host,
8 | kSecMatchLimit as String: kSecMatchLimitOne,
9 | kSecReturnAttributes as String: true,
10 | kSecReturnData as String: true,
11 | kSecAttrLabel as String: "Tart Credentials",
12 | ]
13 |
14 | var item: CFTypeRef?
15 | let status = SecItemCopyMatching(query as CFDictionary, &item)
16 |
17 | if status != errSecSuccess {
18 | if status == errSecItemNotFound {
19 | return nil
20 | }
21 |
22 | throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
23 | }
24 |
25 | guard let item = item as? [String: Any],
26 | let user = item[kSecAttrAccount as String] as? String,
27 | let passwordData = item[kSecValueData as String] as? Data,
28 | let password = String(data: passwordData, encoding: .utf8)
29 | else {
30 | throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
31 | }
32 |
33 | return (user, password)
34 | }
35 |
36 | func store(host: String, user: String, password: String) throws {
37 | let passwordData = password.data(using: .utf8)
38 | let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
39 | kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
40 | kSecAttrServer as String: host,
41 | kSecAttrLabel as String: "Tart Credentials",
42 | ]
43 | let value: [String: Any] = [kSecAttrAccount as String: user,
44 | kSecValueData as String: passwordData,
45 | ]
46 |
47 | let status = SecItemCopyMatching(key as CFDictionary, nil)
48 |
49 | switch status {
50 | case errSecItemNotFound:
51 | let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
52 | if status != errSecSuccess {
53 | throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
54 | }
55 | case errSecSuccess:
56 | let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
57 | if status != errSecSuccess {
58 | throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
59 | }
60 | default:
61 | throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
62 | }
63 | }
64 |
65 | func remove(host: String) throws {
66 | let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
67 | kSecAttrServer as String: host,
68 | kSecAttrLabel as String: "Tart Credentials",
69 | ]
70 |
71 | let status = SecItemDelete(query as CFDictionary)
72 |
73 | switch status {
74 | case errSecSuccess:
75 | return
76 | case errSecItemNotFound:
77 | return
78 | default:
79 | throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
80 | }
81 | }
82 | }
83 |
84 | extension OSStatus {
85 | func explanation() -> CFString {
86 | SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/Sources/tart/Credentials/StdinCredentials.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum StdinCredentialsError: Error {
4 | case CredentialRequired(which: String)
5 | case CredentialTooLong(message: String)
6 | }
7 |
8 | class StdinCredentials {
9 | static func retrieve() throws -> (String, String) {
10 | let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
11 | let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
12 |
13 | return (user, password)
14 | }
15 |
16 | private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
17 | var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
18 | guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
19 | throw StdinCredentialsError.CredentialRequired(which: name)
20 | }
21 |
22 | let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
23 |
24 | if credential.count > maxCharacters {
25 | throw StdinCredentialsError.CredentialTooLong(
26 | message: "\(name) should contain no more than \(maxCharacters) characters")
27 | }
28 |
29 | return credential
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/tart/DeviceInfo/DeviceInfo.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Sysctl
3 |
4 | class DeviceInfo {
5 | private static var osMemoized: String? = nil
6 | private static var modelMemoized: String? = nil
7 |
8 | static var os: String {
9 | if let os = osMemoized {
10 | return os
11 | }
12 |
13 | osMemoized = getOS()
14 |
15 | return osMemoized!
16 | }
17 |
18 | static var model: String {
19 | if let model = modelMemoized {
20 | return model
21 | }
22 |
23 | modelMemoized = getModel()
24 |
25 | return modelMemoized!
26 | }
27 |
28 | private static func getOS() -> String {
29 | let osVersion = ProcessInfo.processInfo.operatingSystemVersion
30 |
31 | return "macOS \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
32 | }
33 |
34 | private static func getModel() -> String {
35 | return SystemControl().hardware.model
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/tart/Fetcher.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import AsyncAlgorithms
3 |
4 | fileprivate let urlSession = createURLSession()
5 |
6 | class Fetcher {
7 | static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel, HTTPURLResponse) {
8 | if viaFile {
9 | return try await fetchViaFile(request)
10 | }
11 |
12 | return try await fetchViaMemory(request)
13 | }
14 |
15 | private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel, HTTPURLResponse) {
16 | let dataCh = AsyncThrowingChannel()
17 |
18 | let (data, response) = try await urlSession.data(for: request)
19 |
20 | Task {
21 | await dataCh.send(data)
22 |
23 | dataCh.finish()
24 | }
25 |
26 | return (dataCh, response as! HTTPURLResponse)
27 | }
28 |
29 | private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel, HTTPURLResponse) {
30 | let dataCh = AsyncThrowingChannel()
31 |
32 | let (fileURL, response) = try await urlSession.download(for: request)
33 |
34 | // Acquire a handle to the downloaded file and then remove it.
35 | //
36 | // This keeps a working reference to that file, yet we don't
37 | // have to deal with the cleanup any more.
38 | let fh = try FileHandle(forReadingFrom: fileURL)
39 | try FileManager.default.removeItem(at: fileURL)
40 |
41 | Task {
42 | while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
43 | await dataCh.send(data)
44 | }
45 |
46 | dataCh.finish()
47 | }
48 |
49 | return (dataCh, response as! HTTPURLResponse)
50 | }
51 | }
52 |
53 | fileprivate func createURLSession() -> URLSession {
54 | let config = URLSessionConfiguration.default
55 |
56 | // Harbor expects a CSRF token to be present if the HTTP client
57 | // carries a session cookie between its requests[1] and fails if
58 | // it was not present[2].
59 | //
60 | // To fix that, we disable the automatic cookies carry in URLSession.
61 | //
62 | // [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
63 | // [2]: https://github.com/cirruslabs/tart/issues/295
64 | config.httpShouldSetCookies = false
65 |
66 | return URLSession(configuration: config)
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/tart/FileLock.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import System
3 |
4 | enum FileLockError: Error, Equatable {
5 | case Failed(_ message: String)
6 | case AlreadyLocked
7 | }
8 |
9 | class FileLock {
10 | let url: URL
11 | let fd: Int32
12 |
13 | init(lockURL: URL) throws {
14 | url = lockURL
15 | fd = open(lockURL.path, 0)
16 | }
17 |
18 | deinit {
19 | close(fd)
20 | }
21 |
22 | func trylock() throws -> Bool {
23 | try flockWrapper(LOCK_EX | LOCK_NB)
24 | }
25 |
26 | func lock() throws {
27 | _ = try flockWrapper(LOCK_EX)
28 | }
29 |
30 | func unlock() throws {
31 | _ = try flockWrapper(LOCK_UN)
32 | }
33 |
34 | func flockWrapper(_ operation: Int32) throws -> Bool {
35 | let ret = flock(fd, operation)
36 | if ret != 0 {
37 | let details = Errno(rawValue: CInt(errno))
38 |
39 | if (operation & LOCK_NB) != 0 && details == .wouldBlock {
40 | return false
41 | }
42 |
43 | throw FileLockError.Failed("failed to lock \(url): \(details)")
44 | }
45 |
46 | return true
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/Sources/tart/Formatter/Format.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import TextTable
4 |
5 | enum Format: String, ExpressibleByArgument, CaseIterable {
6 | case text, json
7 |
8 | private(set) static var allValueStrings: [String] = Format.allCases.map { "\($0)"}
9 |
10 | func renderSingle(_ data: T) -> String where T: Encodable {
11 | switch self {
12 | case .text:
13 | return renderList([data])
14 | case .json:
15 | let encoder = JSONEncoder()
16 | encoder.outputFormatting = .prettyPrinted
17 | return try! encoder.encode(data).asText()
18 | }
19 | }
20 |
21 | func renderList(_ data: Array) -> String where T: Encodable {
22 | switch self {
23 | case .text:
24 | if (data.count == 0) {
25 | return ""
26 | }
27 | let table = TextTable { (item: T) in
28 | let mirroredObject = Mirror(reflecting: item)
29 | return mirroredObject.children.enumerated()
30 | .filter {(_, element) in
31 | // Deprecate the "Running" field: only make it available
32 | // from JSON for backwards-compatibility
33 | element.label! != "Running"
34 | }
35 | .map { (_, element) in
36 | let fieldName = element.label!
37 | return Column(title: fieldName, value: element.value)
38 | }
39 | }
40 | return table.string(for: data, style: Style.plain)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
41 | case .json:
42 | let encoder = JSONEncoder()
43 | encoder.outputFormatting = .prettyPrinted
44 | return try! encoder.encode(data).asText()
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/tart/IPSWCache.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Virtualization
3 |
4 | class IPSWCache: PrunableStorage {
5 | let baseURL: URL
6 |
7 | init() throws {
8 | baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
9 | try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
10 | }
11 |
12 | func locationFor(fileName: String) -> URL {
13 | baseURL.appendingPathComponent(fileName, isDirectory: false)
14 | }
15 |
16 | func prunables() throws -> [Prunable] {
17 | try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
18 | .filter { $0.lastPathComponent.hasSuffix(".ipsw")}
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/tart/Logging/Logger.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol Logger {
4 | func appendNewLine(_ line: String) -> Void
5 | func updateLastLine(_ line: String) -> Void
6 | }
7 |
8 | var defaultLogger: Logger = {
9 | if ProcessInfo.processInfo.environment["CI"] != nil {
10 | return SimpleConsoleLogger()
11 | } else {
12 | return InteractiveConsoleLogger()
13 | }
14 | }()
15 |
16 | public class InteractiveConsoleLogger: Logger {
17 | private let eraseCursorDown = "\u{001B}[J" // clear entire line
18 | private let moveUp = "\u{001B}[1A" // move one line up
19 | private let moveBeginningOfLine = "\r" //
20 |
21 | public init() {
22 |
23 | }
24 |
25 | public func appendNewLine(_ line: String) {
26 | print(line, terminator: "\n")
27 | }
28 |
29 | public func updateLastLine(_ line: String) {
30 | print(moveUp, moveBeginningOfLine, eraseCursorDown, line, separator: "", terminator: "\n")
31 | }
32 | }
33 |
34 | public class SimpleConsoleLogger: Logger {
35 | public init() {
36 |
37 | }
38 |
39 | public func appendNewLine(_ line: String) {
40 | print(line, terminator: "\n")
41 | }
42 |
43 | public func updateLastLine(_ line: String) {
44 | print(line, terminator: "\n")
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/tart/Logging/ProgressObserver.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public class ProgressObserver: NSObject {
4 | @objc var progressToObserve: Progress
5 | var observation: NSKeyValueObservation?
6 | var lastTimeUpdated = Date.now
7 |
8 | public init(_ progress: Progress) {
9 | progressToObserve = progress
10 | }
11 |
12 | func log(_ renderer: Logger) {
13 | renderer.appendNewLine(ProgressObserver.lineToRender(progressToObserve))
14 | observation = observe(\.progressToObserve.fractionCompleted) { progress, _ in
15 | let currentTime = Date.now
16 | if self.progressToObserve.isFinished || currentTime.timeIntervalSince(self.lastTimeUpdated) >= 1.0 {
17 | self.lastTimeUpdated = currentTime
18 | renderer.updateLastLine(ProgressObserver.lineToRender(self.progressToObserve))
19 | }
20 | }
21 | }
22 |
23 | private static func lineToRender(_ progress: Progress) -> String {
24 | String(Int(100 * progress.fractionCompleted)) + "%"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/tart/Logging/URLSessionLogger.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public class URLSessionLogger: NSObject, URLSessionTaskDelegate {
4 | let renderer: Logger
5 |
6 | public init(_ renderer: Logger) {
7 | self.renderer = renderer
8 | }
9 |
10 | public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
11 | renderer.updateLastLine(URLSessionLogger.lineToRender(task.progress))
12 | }
13 |
14 | private static func lineToRender(_ progress: Progress) -> String {
15 | String(100 * progress.completedUnitCount / progress.totalUnitCount) + "%"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/tart/MACAddressResolver/ARPCache.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Network
3 | import Virtualization
4 |
5 | struct ARPCommandFailedError: Error, CustomStringConvertible {
6 | var terminationReason: Process.TerminationReason
7 | var terminationStatus: Int32
8 |
9 | var description: String {
10 | var reason: String
11 |
12 | switch terminationReason {
13 | case .exit:
14 | reason = "exit code \(terminationStatus)"
15 | case .uncaughtSignal:
16 | reason = "uncaught signal"
17 | default:
18 | reason = "unknown reason"
19 | }
20 |
21 | return "arp command failed: \(reason)"
22 | }
23 | }
24 |
25 | struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
26 | var explanation: String
27 |
28 | var description: String {
29 | "arp command yielded invalid output: \(explanation)"
30 | }
31 | }
32 |
33 | struct ARPCacheInternalError: Error, CustomStringConvertible {
34 | var explanation: String
35 |
36 | var description: String {
37 | "ARPCache internal error: \(explanation)"
38 | }
39 | }
40 |
41 | struct ARPCache {
42 | let arpCommandOutput: Data
43 |
44 | init() throws {
45 | let process = Process.init()
46 | process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
47 | process.arguments = ["-an"]
48 |
49 | let pipe = Pipe()
50 | process.standardOutput = pipe
51 | process.standardError = pipe
52 | process.standardInput = FileHandle.nullDevice
53 |
54 | try process.run()
55 | process.waitUntilExit()
56 |
57 | if !(process.terminationReason == .exit && process.terminationStatus == 0) {
58 | throw ARPCommandFailedError(
59 | terminationReason: process.terminationReason,
60 | terminationStatus: process.terminationStatus)
61 | }
62 |
63 | guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else {
64 | throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
65 | }
66 |
67 | self.arpCommandOutput = arpCommandOutput
68 | }
69 |
70 | func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
71 | let lines = String(decoding: arpCommandOutput, as: UTF8.self)
72 | .trimmingCharacters(in: .whitespacesAndNewlines)
73 | .components(separatedBy: "\n")
74 |
75 | // Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
76 | let regex = try NSRegularExpression(pattern: #"^.* \((?.*)\) at (?.*) on (?.*) .*$"#)
77 |
78 | for line in lines {
79 | let nsLineRange = NSRange(line.startIndex.. String {
109 | let nsRange = self.range(withName: name)
110 |
111 | if nsRange.location == NSNotFound {
112 | throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
113 | }
114 |
115 | guard let range = Range.init(nsRange, in: string) else {
116 | throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
117 | }
118 |
119 | return String(string[range])
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/Sources/tart/MACAddressResolver/Lease.swift:
--------------------------------------------------------------------------------
1 | import Network
2 |
3 | struct Lease {
4 | var mac: MACAddress
5 | var ip: IPv4Address
6 |
7 | init?(fromRawLease: [String : String]) {
8 | // Retrieve the required fields
9 | guard let hwAddress = fromRawLease["hw_address"] else { return nil }
10 | guard let ipAddress = fromRawLease["ip_address"] else { return nil }
11 |
12 | // Parse MAC address
13 | let hwAddressSplits = hwAddress.split(separator: ",")
14 | if hwAddressSplits.count != 2 {
15 | return nil
16 | }
17 | if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
18 | return nil
19 | }
20 | guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
21 | return nil
22 | }
23 |
24 | // Parse IP address
25 | guard let ip = IPv4Address(ipAddress) else {
26 | return nil
27 | }
28 |
29 | self.ip = ip
30 | self.mac = mac
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/tart/MACAddressResolver/Leases.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Network
3 |
4 | enum LeasesError: Error {
5 | case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
6 | case Truncated(name: String = "truncated DHCPD leases file")
7 |
8 | var description: String {
9 | switch self {
10 |
11 | case .UnexpectedFormat(name: let name, message: let message, line: let line):
12 | return "\(name) on line \(line): \(message)"
13 | case .Truncated(name: let name):
14 | return "\(name)"
15 | }
16 | }
17 | }
18 |
19 | class Leases {
20 | private let leases: [MACAddress : Lease]
21 |
22 | convenience init?() throws {
23 | try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
24 | }
25 |
26 | convenience init?(_ fromURL: URL) throws {
27 | do {
28 | let urlContents = try String(contentsOf: fromURL, encoding: .utf8)
29 | try self.init(urlContents)
30 | } catch {
31 | if error.isFileNotFound() {
32 | return nil
33 | }
34 |
35 | throw error
36 | }
37 | }
38 |
39 | init(_ fromString: String) throws {
40 | var leases: [MACAddress : Lease] = Dictionary()
41 |
42 | for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
43 | leases[lease.mac] = lease
44 | }
45 |
46 | self.leases = leases
47 | }
48 |
49 | /// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
50 | ///
51 | /// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
52 | private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
53 | var rawLeases: [[String : String]] = Array()
54 |
55 | enum State {
56 | case Nowhere
57 | case Start
58 | case Body
59 | case End
60 | }
61 | var state = State.Nowhere
62 |
63 | var currentRawLease: [String : String] = Dictionary()
64 |
65 | for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
66 | if line == "{" {
67 | // Handle lease block start
68 | if state != .Nowhere && state != .End {
69 | throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
70 | }
71 |
72 | state = .Start
73 | } else if line == "}" {
74 | // Handle lease block end
75 | if state != .Body {
76 | throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
77 | }
78 |
79 | rawLeases.append(currentRawLease)
80 | currentRawLease = Dictionary()
81 |
82 | state = .End
83 | } else {
84 | // Handle lease block contents
85 | let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
86 |
87 | if lineWithoutTabs.isEmpty {
88 | continue
89 | }
90 |
91 | let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
92 | if splits.count != 2 {
93 | throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
94 | }
95 | let (key, value) = (String(splits[0]), String(splits[1]))
96 |
97 | currentRawLease[key] = value
98 |
99 | state = .Body
100 | }
101 | }
102 |
103 | if state == .Start || state == .Body {
104 | throw LeasesError.Truncated()
105 | }
106 |
107 | return rawLeases
108 | }
109 |
110 | func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
111 | leases[macAddress]?.ip
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/Sources/tart/MACAddressResolver/MACAddress.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct MACAddress: Equatable, Hashable, CustomStringConvertible {
4 | var mac: [UInt8] = Array(repeating: 0, count: 6)
5 |
6 | init?(fromString: String) {
7 | let components = fromString.components(separatedBy: ":")
8 |
9 | if components.count != 6 {
10 | return nil
11 | }
12 |
13 | for (index, component) in components.enumerated() {
14 | mac[index] = UInt8(component, radix: 16)!
15 | }
16 | }
17 |
18 | var description: String {
19 | String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/tart/Network/Network.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | protocol Network {
4 | func attachments() -> [VZNetworkDeviceAttachment]
5 | func run(_ sema: DispatchSemaphore) throws
6 | func stop() async throws
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/tart/Network/NetworkBridged.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Virtualization
3 |
4 | class NetworkBridged: Network {
5 | let interfaces: [VZBridgedNetworkInterface]
6 |
7 | init(interfaces: [VZBridgedNetworkInterface]) {
8 | self.interfaces = interfaces
9 | }
10 |
11 | func attachments() -> [VZNetworkDeviceAttachment] {
12 | interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
13 | }
14 |
15 | func run(_ sema: DispatchSemaphore) throws {
16 | // no-op, only used for Softnet
17 | }
18 |
19 | func stop() async throws {
20 | // no-op, only used for Softnet
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/tart/Network/NetworkShared.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Virtualization
3 |
4 | class NetworkShared: Network {
5 | func attachments() -> [VZNetworkDeviceAttachment] {
6 | [VZNATNetworkDeviceAttachment()]
7 | }
8 |
9 | func run(_ sema: DispatchSemaphore) throws {
10 | // no-op, only used for Softnet
11 | }
12 |
13 | func stop() async throws {
14 | // no-op, only used for Softnet
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Authentication.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol Authentication {
4 | func header() -> (String, String)
5 | func isValid() -> Bool
6 | }
7 |
8 | struct BasicAuthentication: Authentication {
9 | let user: String
10 | let password: String
11 |
12 | func header() -> (String, String) {
13 | let creds = Data("\(user):\(password)".utf8).base64EncodedString()
14 |
15 | return ("Authorization", "Basic \(creds)")
16 | }
17 |
18 | func isValid() -> Bool {
19 | true
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Digest.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import CryptoKit
3 |
4 | class Digest {
5 | var hash: SHA256 = SHA256()
6 |
7 | func update(_ data: Data) {
8 | hash.update(data: data)
9 | }
10 |
11 | func finalize() -> String {
12 | hash.finalize().hexdigest()
13 | }
14 |
15 | static func hash(_ data: Data) -> String {
16 | SHA256.hash(data: data).hexdigest()
17 | }
18 | }
19 |
20 | extension SHA256.Digest {
21 | func hexdigest() -> String {
22 | "sha256:" + self.map {
23 | String(format: "%02x", $0)
24 | }
25 | .joined()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Manifest.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
4 | let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
5 |
6 | // Annotations
7 | let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
8 | let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
9 |
10 | struct OCIManifest: Codable, Equatable {
11 | var schemaVersion: Int = 2
12 | var mediaType: String = ociManifestMediaType
13 | var config: OCIManifestConfig
14 | var layers: [OCIManifestLayer] = Array()
15 | var annotations: Dictionary?
16 |
17 | init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil, uploadDate: Date? = nil) {
18 | self.config = config
19 | self.layers = layers
20 |
21 | var annotations: [String: String] = [:]
22 |
23 | if let uncompressedDiskSize = uncompressedDiskSize {
24 | annotations[uncompressedDiskSizeAnnotation] = String(uncompressedDiskSize)
25 | }
26 |
27 | if let uploadDate = uploadDate {
28 | annotations[uploadTimeAnnotation] = uploadDate.toISO()
29 | }
30 |
31 | self.annotations = annotations
32 | }
33 |
34 | init(fromJSON: Data) throws {
35 | self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
36 | }
37 |
38 | func toJSON() throws -> Data {
39 | try Config.jsonEncoder().encode(self)
40 | }
41 |
42 | func digest() throws -> String {
43 | try Digest.hash(toJSON())
44 | }
45 |
46 | func uncompressedDiskSize() -> UInt64? {
47 | guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
48 | return nil
49 | }
50 |
51 | return UInt64(value)
52 | }
53 | }
54 |
55 | struct OCIConfig: Codable {
56 | var architecture: Architecture = .arm64
57 | var os: OS = .darwin
58 |
59 | func toJSON() throws -> Data {
60 | try Config.jsonEncoder().encode(self)
61 | }
62 | }
63 |
64 | struct OCIManifestConfig: Codable, Equatable {
65 | var mediaType: String = ociConfigMediaType
66 | var size: Int
67 | var digest: String
68 | }
69 |
70 | struct OCIManifestLayer: Codable, Equatable {
71 | var mediaType: String
72 | var size: Int
73 | var digest: String
74 | }
75 |
76 | struct Descriptor: Equatable {
77 | var size: Int
78 | var digest: String
79 | }
80 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/Reference.interp:
--------------------------------------------------------------------------------
1 | token literal names:
2 | null
3 | ':'
4 | '/'
5 | '.'
6 | '-'
7 | '@'
8 | '_'
9 | null
10 | null
11 |
12 | token symbolic names:
13 | null
14 | null
15 | null
16 | null
17 | null
18 | null
19 | null
20 | DIGIT
21 | LETTER
22 |
23 | rule names:
24 | root
25 | host
26 | port
27 | host_component
28 | namespace
29 | namespace_component
30 | reference
31 | tag
32 | separator
33 | name
34 |
35 |
36 | atn:
37 | [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]
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/Reference.tokens:
--------------------------------------------------------------------------------
1 | T__0=1
2 | T__1=2
3 | T__2=3
4 | T__3=4
5 | T__4=5
6 | T__5=6
7 | DIGIT=7
8 | LETTER=8
9 | ':'=1
10 | '/'=2
11 | '.'=3
12 | '-'=4
13 | '@'=5
14 | '_'=6
15 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/ReferenceBaseListener.swift:
--------------------------------------------------------------------------------
1 | // Generated from java-escape by ANTLR 4.11.1
2 |
3 | import Antlr4
4 |
5 |
6 | /**
7 | * This class provides an empty implementation of {@link ReferenceListener},
8 | * which can be extended to create a listener which only needs to handle a subset
9 | * of the available methods.
10 | */
11 | open class ReferenceBaseListener: ReferenceListener {
12 | public init() { }
13 | /**
14 | * {@inheritDoc}
15 | *
16 | * The default implementation does nothing.
17 | */
18 | open func enterRoot(_ ctx: ReferenceParser.RootContext) { }
19 | /**
20 | * {@inheritDoc}
21 | *
22 | * The default implementation does nothing.
23 | */
24 | open func exitRoot(_ ctx: ReferenceParser.RootContext) { }
25 |
26 | /**
27 | * {@inheritDoc}
28 | *
29 | * The default implementation does nothing.
30 | */
31 | open func enterHost(_ ctx: ReferenceParser.HostContext) { }
32 | /**
33 | * {@inheritDoc}
34 | *
35 | * The default implementation does nothing.
36 | */
37 | open func exitHost(_ ctx: ReferenceParser.HostContext) { }
38 |
39 | /**
40 | * {@inheritDoc}
41 | *
42 | * The default implementation does nothing.
43 | */
44 | open func enterPort(_ ctx: ReferenceParser.PortContext) { }
45 | /**
46 | * {@inheritDoc}
47 | *
48 | * The default implementation does nothing.
49 | */
50 | open func exitPort(_ ctx: ReferenceParser.PortContext) { }
51 |
52 | /**
53 | * {@inheritDoc}
54 | *
55 | * The default implementation does nothing.
56 | */
57 | open func enterHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
58 | /**
59 | * {@inheritDoc}
60 | *
61 | * The default implementation does nothing.
62 | */
63 | open func exitHost_component(_ ctx: ReferenceParser.Host_componentContext) { }
64 |
65 | /**
66 | * {@inheritDoc}
67 | *
68 | * The default implementation does nothing.
69 | */
70 | open func enterNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
71 | /**
72 | * {@inheritDoc}
73 | *
74 | * The default implementation does nothing.
75 | */
76 | open func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) { }
77 |
78 | /**
79 | * {@inheritDoc}
80 | *
81 | * The default implementation does nothing.
82 | */
83 | open func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
84 | /**
85 | * {@inheritDoc}
86 | *
87 | * The default implementation does nothing.
88 | */
89 | open func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext) { }
90 |
91 | /**
92 | * {@inheritDoc}
93 | *
94 | * The default implementation does nothing.
95 | */
96 | open func enterReference(_ ctx: ReferenceParser.ReferenceContext) { }
97 | /**
98 | * {@inheritDoc}
99 | *
100 | * The default implementation does nothing.
101 | */
102 | open func exitReference(_ ctx: ReferenceParser.ReferenceContext) { }
103 |
104 | /**
105 | * {@inheritDoc}
106 | *
107 | * The default implementation does nothing.
108 | */
109 | open func enterTag(_ ctx: ReferenceParser.TagContext) { }
110 | /**
111 | * {@inheritDoc}
112 | *
113 | * The default implementation does nothing.
114 | */
115 | open func exitTag(_ ctx: ReferenceParser.TagContext) { }
116 |
117 | /**
118 | * {@inheritDoc}
119 | *
120 | * The default implementation does nothing.
121 | */
122 | open func enterSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
123 | /**
124 | * {@inheritDoc}
125 | *
126 | * The default implementation does nothing.
127 | */
128 | open func exitSeparator(_ ctx: ReferenceParser.SeparatorContext) { }
129 |
130 | /**
131 | * {@inheritDoc}
132 | *
133 | * The default implementation does nothing.
134 | */
135 | open func enterName(_ ctx: ReferenceParser.NameContext) { }
136 | /**
137 | * {@inheritDoc}
138 | *
139 | * The default implementation does nothing.
140 | */
141 | open func exitName(_ ctx: ReferenceParser.NameContext) { }
142 |
143 | /**
144 | * {@inheritDoc}
145 | *
146 | * The default implementation does nothing.
147 | */
148 | open func enterEveryRule(_ ctx: ParserRuleContext) throws { }
149 | /**
150 | * {@inheritDoc}
151 | *
152 | * The default implementation does nothing.
153 | */
154 | open func exitEveryRule(_ ctx: ParserRuleContext) throws { }
155 | /**
156 | * {@inheritDoc}
157 | *
158 | * The default implementation does nothing.
159 | */
160 | open func visitTerminal(_ node: TerminalNode) { }
161 | /**
162 | * {@inheritDoc}
163 | *
164 | * The default implementation does nothing.
165 | */
166 | open func visitErrorNode(_ node: ErrorNode) { }
167 | }
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/ReferenceLexer.interp:
--------------------------------------------------------------------------------
1 | token literal names:
2 | null
3 | ':'
4 | '/'
5 | '.'
6 | '-'
7 | '@'
8 | '_'
9 | null
10 | null
11 |
12 | token symbolic names:
13 | null
14 | null
15 | null
16 | null
17 | null
18 | null
19 | null
20 | DIGIT
21 | LETTER
22 |
23 | rule names:
24 | T__0
25 | T__1
26 | T__2
27 | T__3
28 | T__4
29 | T__5
30 | DIGIT
31 | LETTER
32 |
33 | channel names:
34 | DEFAULT_TOKEN_CHANNEL
35 | HIDDEN
36 |
37 | mode names:
38 | DEFAULT_MODE
39 |
40 | atn:
41 | [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]
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/ReferenceLexer.swift:
--------------------------------------------------------------------------------
1 | // Generated from java-escape by ANTLR 4.11.1
2 | import Antlr4
3 |
4 | open class ReferenceLexer: Lexer {
5 |
6 | internal static var _decisionToDFA: [DFA] = {
7 | var decisionToDFA = [DFA]()
8 | let length = ReferenceLexer._ATN.getNumberOfDecisions()
9 | for i in 0.. Vocabulary {
47 | return ReferenceLexer.VOCABULARY
48 | }
49 |
50 | public
51 | required init(_ input: CharStream) {
52 | RuntimeMetaData.checkVersion("4.11.1", RuntimeMetaData.VERSION)
53 | super.init(input)
54 | _interp = LexerATNSimulator(self, ReferenceLexer._ATN, ReferenceLexer._decisionToDFA, ReferenceLexer._sharedContextCache)
55 | }
56 |
57 | override open
58 | func getGrammarFileName() -> String { return "Reference.g4" }
59 |
60 | override open
61 | func getRuleNames() -> [String] { return ReferenceLexer.ruleNames }
62 |
63 | override open
64 | func getSerializedATN() -> [Int] { return ReferenceLexer._serializedATN }
65 |
66 | override open
67 | func getChannelNames() -> [String] { return ReferenceLexer.channelNames }
68 |
69 | override open
70 | func getModeNames() -> [String] { return ReferenceLexer.modeNames }
71 |
72 | override open
73 | func getATN() -> ATN { return ReferenceLexer._ATN }
74 |
75 | static let _serializedATN:[Int] = [
76 | 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,
77 | 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,
78 | 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,
79 | 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,
80 | 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,
81 | 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,
82 | 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,
83 | 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,
84 | 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,
85 | 32,16,1,0,0,0,1,0,0
86 | ]
87 |
88 | public
89 | static let _ATN: ATN = try! ATNDeserializer().deserialize(_serializedATN)
90 | }
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/ReferenceLexer.tokens:
--------------------------------------------------------------------------------
1 | T__0=1
2 | T__1=2
3 | T__2=3
4 | T__3=4
5 | T__4=5
6 | T__5=6
7 | DIGIT=7
8 | LETTER=8
9 | ':'=1
10 | '/'=2
11 | '.'=3
12 | '-'=4
13 | '@'=5
14 | '_'=6
15 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Generated/ReferenceListener.swift:
--------------------------------------------------------------------------------
1 | // Generated from java-escape by ANTLR 4.11.1
2 | import Antlr4
3 |
4 | /**
5 | * This interface defines a complete listener for a parse tree produced by
6 | * {@link ReferenceParser}.
7 | */
8 | public protocol ReferenceListener: ParseTreeListener {
9 | /**
10 | * Enter a parse tree produced by {@link ReferenceParser#root}.
11 | - Parameters:
12 | - ctx: the parse tree
13 | */
14 | func enterRoot(_ ctx: ReferenceParser.RootContext)
15 | /**
16 | * Exit a parse tree produced by {@link ReferenceParser#root}.
17 | - Parameters:
18 | - ctx: the parse tree
19 | */
20 | func exitRoot(_ ctx: ReferenceParser.RootContext)
21 | /**
22 | * Enter a parse tree produced by {@link ReferenceParser#host}.
23 | - Parameters:
24 | - ctx: the parse tree
25 | */
26 | func enterHost(_ ctx: ReferenceParser.HostContext)
27 | /**
28 | * Exit a parse tree produced by {@link ReferenceParser#host}.
29 | - Parameters:
30 | - ctx: the parse tree
31 | */
32 | func exitHost(_ ctx: ReferenceParser.HostContext)
33 | /**
34 | * Enter a parse tree produced by {@link ReferenceParser#port}.
35 | - Parameters:
36 | - ctx: the parse tree
37 | */
38 | func enterPort(_ ctx: ReferenceParser.PortContext)
39 | /**
40 | * Exit a parse tree produced by {@link ReferenceParser#port}.
41 | - Parameters:
42 | - ctx: the parse tree
43 | */
44 | func exitPort(_ ctx: ReferenceParser.PortContext)
45 | /**
46 | * Enter a parse tree produced by {@link ReferenceParser#host_component}.
47 | - Parameters:
48 | - ctx: the parse tree
49 | */
50 | func enterHost_component(_ ctx: ReferenceParser.Host_componentContext)
51 | /**
52 | * Exit a parse tree produced by {@link ReferenceParser#host_component}.
53 | - Parameters:
54 | - ctx: the parse tree
55 | */
56 | func exitHost_component(_ ctx: ReferenceParser.Host_componentContext)
57 | /**
58 | * Enter a parse tree produced by {@link ReferenceParser#namespace}.
59 | - Parameters:
60 | - ctx: the parse tree
61 | */
62 | func enterNamespace(_ ctx: ReferenceParser.NamespaceContext)
63 | /**
64 | * Exit a parse tree produced by {@link ReferenceParser#namespace}.
65 | - Parameters:
66 | - ctx: the parse tree
67 | */
68 | func exitNamespace(_ ctx: ReferenceParser.NamespaceContext)
69 | /**
70 | * Enter a parse tree produced by {@link ReferenceParser#namespace_component}.
71 | - Parameters:
72 | - ctx: the parse tree
73 | */
74 | func enterNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
75 | /**
76 | * Exit a parse tree produced by {@link ReferenceParser#namespace_component}.
77 | - Parameters:
78 | - ctx: the parse tree
79 | */
80 | func exitNamespace_component(_ ctx: ReferenceParser.Namespace_componentContext)
81 | /**
82 | * Enter a parse tree produced by {@link ReferenceParser#reference}.
83 | - Parameters:
84 | - ctx: the parse tree
85 | */
86 | func enterReference(_ ctx: ReferenceParser.ReferenceContext)
87 | /**
88 | * Exit a parse tree produced by {@link ReferenceParser#reference}.
89 | - Parameters:
90 | - ctx: the parse tree
91 | */
92 | func exitReference(_ ctx: ReferenceParser.ReferenceContext)
93 | /**
94 | * Enter a parse tree produced by {@link ReferenceParser#tag}.
95 | - Parameters:
96 | - ctx: the parse tree
97 | */
98 | func enterTag(_ ctx: ReferenceParser.TagContext)
99 | /**
100 | * Exit a parse tree produced by {@link ReferenceParser#tag}.
101 | - Parameters:
102 | - ctx: the parse tree
103 | */
104 | func exitTag(_ ctx: ReferenceParser.TagContext)
105 | /**
106 | * Enter a parse tree produced by {@link ReferenceParser#separator}.
107 | - Parameters:
108 | - ctx: the parse tree
109 | */
110 | func enterSeparator(_ ctx: ReferenceParser.SeparatorContext)
111 | /**
112 | * Exit a parse tree produced by {@link ReferenceParser#separator}.
113 | - Parameters:
114 | - ctx: the parse tree
115 | */
116 | func exitSeparator(_ ctx: ReferenceParser.SeparatorContext)
117 | /**
118 | * Enter a parse tree produced by {@link ReferenceParser#name}.
119 | - Parameters:
120 | - ctx: the parse tree
121 | */
122 | func enterName(_ ctx: ReferenceParser.NameContext)
123 | /**
124 | * Exit a parse tree produced by {@link ReferenceParser#name}.
125 | - Parameters:
126 | - ctx: the parse tree
127 | */
128 | func exitName(_ ctx: ReferenceParser.NameContext)
129 | }
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Makefile:
--------------------------------------------------------------------------------
1 | all: clean
2 | antlr -o Generated -Dlanguage=Swift Reference.g4
3 |
4 | clean:
5 | rm -rf Generated
6 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/Reference/Reference.g4:
--------------------------------------------------------------------------------
1 | grammar Reference;
2 |
3 | root: host (':' port)? '/' namespace reference? EOF;
4 | host: host_component ('.' host_component)*;
5 | port: DIGIT+;
6 | host_component: name ('-' name)*;
7 | namespace: namespace_component ('/' namespace_component)*;
8 | namespace_component: (name separator?)+;
9 | reference: (':' tag) | ('@' name ':' name);
10 | tag: name (separator name)*;
11 | separator: '.' | '-' | '_';
12 | name: (LETTER | DIGIT)+;
13 | DIGIT: [0-9];
14 | LETTER: [A-Za-z];
15 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/RemoteName.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Antlr4
3 |
4 | struct Reference: Comparable, Hashable, CustomStringConvertible {
5 | enum ReferenceType: Comparable {
6 | case Tag
7 | case Digest
8 | }
9 |
10 | let type: ReferenceType
11 | let value: String
12 |
13 | var fullyQualified: String {
14 | get {
15 | switch type {
16 | case .Tag:
17 | return ":" + value
18 | case .Digest:
19 | return "@" + value
20 | }
21 | }
22 | }
23 |
24 | init(tag: String) {
25 | type = .Tag
26 | value = tag
27 | }
28 |
29 | init(digest: String) {
30 | type = .Digest
31 | value = digest
32 | }
33 |
34 | static func <(lhs: Reference, rhs: Reference) -> Bool {
35 | if lhs.type != rhs.type {
36 | return lhs.type < rhs.type
37 | } else {
38 | return lhs.value < rhs.value
39 | }
40 | }
41 |
42 | var description: String {
43 | get {
44 | fullyQualified
45 | }
46 | }
47 | }
48 |
49 | class ReferenceCollector: ReferenceBaseListener {
50 | var host: String? = nil
51 | var port: String? = nil
52 | var namespace: String? = nil
53 | var reference: String? = nil
54 |
55 | override func exitHost(_ ctx: ReferenceParser.HostContext) {
56 | host = ctx.getText()
57 | }
58 |
59 | override func exitPort(_ ctx: ReferenceParser.PortContext) {
60 | port = ctx.getText()
61 | }
62 |
63 | override func exitNamespace(_ ctx: ReferenceParser.NamespaceContext) {
64 | namespace = ctx.getText()
65 | }
66 |
67 | override func exitReference(_ ctx: ReferenceParser.ReferenceContext) {
68 | reference = ctx.getText()
69 | }
70 | }
71 |
72 | class ErrorCollector: BaseErrorListener {
73 | var error: String? = nil
74 |
75 | override func syntaxError(_ recognizer: Recognizer, _ offendingSymbol: AnyObject?, _ line: Int, _ charPositionInLine: Int, _ msg: String, _ e: AnyObject?) {
76 | if error == nil {
77 | error = "\(msg) (character \(charPositionInLine + 1))"
78 | }
79 | }
80 | }
81 |
82 | struct RemoteName: Comparable, Hashable, CustomStringConvertible {
83 | var host: String
84 | var namespace: String
85 | var reference: Reference
86 |
87 | init(host: String, namespace: String, reference: Reference) {
88 | self.host = host
89 | self.namespace = namespace
90 | self.reference = reference
91 | }
92 |
93 | init(_ name: String) throws {
94 | let errorCollector = ErrorCollector()
95 | let inputStream = ANTLRInputStream(Array(name.unicodeScalars), name.count)
96 | let lexer = ReferenceLexer(inputStream)
97 | lexer.removeErrorListeners()
98 | lexer.addErrorListener(errorCollector)
99 |
100 | let tokenStream = CommonTokenStream(lexer)
101 | let parser = try ReferenceParser(tokenStream)
102 | parser.removeErrorListeners()
103 | parser.addErrorListener(errorCollector)
104 |
105 | let referenceCollector = ReferenceCollector()
106 | try ParseTreeWalker().walk(referenceCollector, try parser.root())
107 |
108 | if let error = errorCollector.error {
109 | throw RuntimeError.FailedToParseRemoteName("\(error)")
110 | }
111 |
112 | host = referenceCollector.host!
113 | if let port = referenceCollector.port {
114 | host += ":" + port
115 | }
116 | namespace = referenceCollector.namespace!
117 | if let reference = referenceCollector.reference {
118 | if reference.starts(with: "@sha256:") {
119 | self.reference = Reference(digest: String(reference.dropFirst(1)))
120 | } else if reference.starts(with: ":") {
121 | self.reference = Reference(tag: String(reference.dropFirst(1)))
122 | } else {
123 | throw RuntimeError.FailedToParseRemoteName("unknown reference format")
124 | }
125 | } else {
126 | self.reference = Reference(tag: "latest")
127 | }
128 | }
129 |
130 | static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
131 | if lhs.host != rhs.host {
132 | return lhs.host < rhs.host
133 | } else if lhs.namespace != rhs.namespace {
134 | return lhs.namespace < rhs.namespace
135 | } else {
136 | return lhs.reference < rhs.reference
137 | }
138 | }
139 |
140 | var description: String {
141 | "\(host)/\(namespace)\(reference.fullyQualified)"
142 | }
143 | }
144 |
145 | extension Array where Self.Element == ClosedRange {
146 | func asCharacterSet() -> CharacterSet {
147 | let characters = self.joined().map { String(UnicodeScalar($0)) }.joined()
148 | return CharacterSet(charactersIn: characters)
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/URL+Absolutize.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL {
4 | func absolutize(_ baseURL: URL) -> Self {
5 | URL(string: absoluteString, relativeTo: baseURL)!
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/tart/OCI/WWWAuthenticate.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // WWW-Authenticate header parser based on details from RFCs[1][2]
4 | ///
5 | // [1]: https://www.rfc-editor.org/rfc/rfc2617#section-3.2.1
6 | // [2]: https://www.rfc-editor.org/rfc/rfc6750#section-3
7 | class WWWAuthenticate {
8 | var scheme: String
9 | var kvs: Dictionary = Dictionary()
10 |
11 | init(rawHeaderValue: String) throws {
12 | let splits = rawHeaderValue.split(separator: " ", maxSplits: 1)
13 |
14 | if splits.count == 2 {
15 | scheme = String(splits[0])
16 | } else {
17 | throw RegistryError.MalformedHeader(why: "WWW-Authenticate header should consist of two parts: "
18 | + "scheme and directives")
19 | }
20 |
21 | let rawDirectives = contextAwareCommaSplit(rawDirectives: String(splits[1]))
22 |
23 | try rawDirectives.forEach { sequence in
24 | let parts = sequence.split(separator: "=", maxSplits: 1)
25 | if parts.count != 2 {
26 | throw RegistryError.MalformedHeader(why: "Each WWW-Authenticate header directive should be in the form of "
27 | + "key=value or key=\"value\"")
28 | }
29 |
30 | let key = String(parts[0])
31 | var value = String(parts[1])
32 | value = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
33 |
34 | kvs[key] = value
35 | }
36 | }
37 |
38 | private func contextAwareCommaSplit(rawDirectives: String) -> Array {
39 | var result: Array = Array()
40 | var inQuotation: Bool = false
41 | var accumulator: Array = Array()
42 |
43 | for ch in rawDirectives {
44 | if ch == "," && !inQuotation {
45 | result.append(String(accumulator))
46 | accumulator.removeAll()
47 | continue
48 | }
49 |
50 | accumulator.append(ch)
51 |
52 | if ch == "\"" {
53 | inQuotation.toggle()
54 | }
55 | }
56 |
57 | if !accumulator.isEmpty {
58 | result.append(String(accumulator))
59 | }
60 |
61 | return result
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/tart/PIDLock.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import System
3 |
4 | class PIDLock {
5 | let url: URL
6 | let fd: Int32
7 |
8 | init(lockURL: URL) throws {
9 | url = lockURL
10 | fd = open(lockURL.path, O_RDWR)
11 | if fd == -1 {
12 | let details = Errno(rawValue: CInt(errno))
13 |
14 | throw RuntimeError.PIDLockFailed("failed to open lock file \(url): \(details)")
15 | }
16 | }
17 |
18 | deinit {
19 | close(fd)
20 | }
21 |
22 | func trylock() throws -> Bool {
23 | let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
24 | return locked
25 | }
26 |
27 | func lock() throws {
28 | _ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
29 | }
30 |
31 | func unlock() throws {
32 | _ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
33 | }
34 |
35 | func pid() throws -> pid_t {
36 | let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
37 |
38 | return result.l_pid
39 | }
40 |
41 | func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
42 | var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
43 |
44 | let ret = fcntl(fd, operation, &result)
45 | if ret != 0 {
46 | if operation == F_SETLK && errno == EAGAIN {
47 | return (false, result)
48 | }
49 |
50 | let details = Errno(rawValue: CInt(errno))
51 |
52 | throw RuntimeError.PIDLockFailed("\(message): \(details)")
53 | }
54 |
55 | return (true, result)
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/tart/Passphrase/PassphraseGenerator.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct PassphraseGenerator: Sequence {
4 | func makeIterator() -> PassphraseIterator {
5 | PassphraseIterator()
6 | }
7 | }
8 |
9 | struct PassphraseIterator: IteratorProtocol {
10 | mutating func next() -> String? {
11 | passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/tart/Platform/Architecture.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum Architecture: String, Codable {
4 | case arm64
5 | case amd64
6 | }
7 |
8 | func CurrentArchitecture() -> Architecture {
9 | #if arch(arm64)
10 | return .arm64
11 | #elseif arch(x86_64)
12 | return .amd64
13 | #endif
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/tart/Platform/Darwin.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | struct UnsupportedHostOSError: Error, CustomStringConvertible {
4 | var description: String {
5 | "error: host macOS version is outdated to run this virtual machine"
6 | }
7 | }
8 |
9 | struct Darwin: PlatformSuspendable {
10 | var ecid: VZMacMachineIdentifier
11 | var hardwareModel: VZMacHardwareModel
12 |
13 | init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
14 | self.ecid = ecid
15 | self.hardwareModel = hardwareModel
16 | }
17 |
18 | init(from decoder: Decoder) throws {
19 | let container = try decoder.container(keyedBy: CodingKeys.self)
20 |
21 | let encodedECID = try container.decode(String.self, forKey: .ecid)
22 | guard let data = Data.init(base64Encoded: encodedECID) else {
23 | throw DecodingError.dataCorruptedError(forKey: .ecid,
24 | in: container,
25 | debugDescription: "failed to initialize Data using the provided value")
26 | }
27 | guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
28 | throw DecodingError.dataCorruptedError(forKey: .ecid,
29 | in: container,
30 | debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
31 | }
32 | self.ecid = ecid
33 |
34 | let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
35 | guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
36 | throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
37 | }
38 | guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
39 | throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
40 | }
41 | self.hardwareModel = hardwareModel
42 | }
43 |
44 | func encode(to encoder: Encoder) throws {
45 | var container = encoder.container(keyedBy: CodingKeys.self)
46 |
47 | try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
48 | try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
49 | }
50 |
51 | func os() -> OS {
52 | .darwin
53 | }
54 |
55 | func bootLoader(nvramURL: URL) throws -> VZBootLoader {
56 | VZMacOSBootLoader()
57 | }
58 |
59 | func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
60 | let result = VZMacPlatformConfiguration()
61 |
62 | result.machineIdentifier = ecid
63 | result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
64 |
65 | if !hardwareModel.isSupported {
66 | // At the moment support of M1 chip is not yet dropped in any macOS version
67 | // This mean that host software is not supporting this hardware model and should be updated
68 | throw UnsupportedHostOSError()
69 | }
70 |
71 | result.hardwareModel = hardwareModel
72 |
73 | return result
74 | }
75 |
76 | func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
77 | let result = VZMacGraphicsDeviceConfiguration()
78 |
79 | if let hostMainScreen = NSScreen.main {
80 | let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
81 | result.displays = [
82 | VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
83 | ]
84 |
85 | return result
86 | }
87 |
88 | result.displays = [
89 | VZMacGraphicsDisplayConfiguration(
90 | widthInPixels: vmConfig.display.width,
91 | heightInPixels: vmConfig.display.height,
92 | // A reasonable guess according to Apple's documentation[1]
93 | // [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
94 | pixelsPerInch: 72
95 | )
96 | ]
97 |
98 | return result
99 | }
100 |
101 | func keyboards() -> [VZKeyboardConfiguration] {
102 | if #available(macOS 14, *) {
103 | // Mac keyboard is only supported by guests starting with macOS Ventura
104 | return [VZMacKeyboardConfiguration(), VZUSBKeyboardConfiguration()]
105 | } else {
106 | return [VZUSBKeyboardConfiguration()]
107 | }
108 | }
109 |
110 | func keyboardsSuspendable() -> [VZKeyboardConfiguration] {
111 | if #available(macOS 14, *) {
112 | return [VZMacKeyboardConfiguration()]
113 | } else {
114 | return []
115 | }
116 | }
117 |
118 | func pointingDevices() -> [VZPointingDeviceConfiguration] {
119 | if #available(macOS 13, *) {
120 | // Trackpad is only supported by guests starting with macOS Ventura
121 | return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
122 | } else {
123 | return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
124 | }
125 | }
126 |
127 | func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
128 | if #available(macOS 13, *) {
129 | return [VZMacTrackpadConfiguration()]
130 | } else {
131 | return []
132 | }
133 | }
134 | }
135 |
--------------------------------------------------------------------------------
/Sources/tart/Platform/Linux.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | @available(macOS 13, *)
4 | struct Linux: Platform {
5 | func os() -> OS {
6 | .linux
7 | }
8 |
9 | func bootLoader(nvramURL: URL) throws -> VZBootLoader {
10 | let result = VZEFIBootLoader()
11 |
12 | result.variableStore = VZEFIVariableStore(url: nvramURL)
13 |
14 | return result
15 | }
16 |
17 | func platform(nvramURL: URL) throws -> VZPlatformConfiguration {
18 | VZGenericPlatformConfiguration()
19 | }
20 |
21 | func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
22 | let result = VZVirtioGraphicsDeviceConfiguration()
23 |
24 | result.scanouts = [
25 | VZVirtioGraphicsScanoutConfiguration(
26 | widthInPixels: vmConfig.display.width,
27 | heightInPixels: vmConfig.display.height
28 | )
29 | ]
30 |
31 | return result
32 | }
33 |
34 | func keyboards() -> [VZKeyboardConfiguration] {
35 | [VZUSBKeyboardConfiguration()]
36 | }
37 |
38 | func pointingDevices() -> [VZPointingDeviceConfiguration] {
39 | [VZUSBScreenCoordinatePointingDeviceConfiguration()]
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/tart/Platform/OS.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | enum OS: String, Codable {
4 | case darwin
5 | case linux
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/tart/Platform/Platform.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | protocol Platform: Codable {
4 | func os() -> OS
5 | func bootLoader(nvramURL: URL) throws -> VZBootLoader
6 | func platform(nvramURL: URL) throws -> VZPlatformConfiguration
7 | func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
8 | func keyboards() -> [VZKeyboardConfiguration]
9 | func pointingDevices() -> [VZPointingDeviceConfiguration]
10 | }
11 |
12 | protocol PlatformSuspendable: Platform {
13 | func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration]
14 | func keyboardsSuspendable() -> [VZKeyboardConfiguration]
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/tart/Prunable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol PrunableStorage {
4 | func prunables() throws -> [Prunable]
5 | }
6 |
7 | protocol Prunable {
8 | var url: URL { get }
9 | func delete() throws
10 | func accessDate() throws -> Date
11 | func sizeBytes() throws -> Int
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/tart/Root.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Darwin
3 | import Foundation
4 | import Sentry
5 |
6 | @main
7 | struct Root: AsyncParsableCommand {
8 | static var configuration = CommandConfiguration(
9 | commandName: "tart",
10 | version: CI.version,
11 | subcommands: [
12 | Create.self,
13 | Clone.self,
14 | Run.self,
15 | Set.self,
16 | Get.self,
17 | List.self,
18 | Login.self,
19 | Logout.self,
20 | IP.self,
21 | Pull.self,
22 | Push.self,
23 | Import.self,
24 | Export.self,
25 | Prune.self,
26 | Rename.self,
27 | Stop.self,
28 | Delete.self,
29 | ])
30 |
31 | public static func main() async throws {
32 | // Initialize Sentry
33 | if let dsn = ProcessInfo.processInfo.environment["SENTRY_DSN"] {
34 | SentrySDK.start { options in
35 | options.dsn = dsn
36 | options.releaseName = CI.release
37 | options.tracesSampleRate = Float(
38 | ProcessInfo.processInfo.environment["SENTRY_TRACES_SAMPLE_RATE"] ?? "1.0"
39 | ) as NSNumber?
40 |
41 | // By default only 5XX are captured
42 | // Let's capture everything but 401 (unauthorized)
43 | options.enableCaptureFailedRequests = true
44 | options.failedRequestStatusCodes = [
45 | HttpStatusCodeRange(min: 400, max: 400),
46 | HttpStatusCodeRange(min: 402, max: 599)
47 | ]
48 | }
49 | }
50 | defer { SentrySDK.flush(timeout: 2.seconds.timeInterval) }
51 |
52 | // Enrich future events with Cirrus CI-specific tags
53 | if let tags = ProcessInfo.processInfo.environment["CIRRUS_SENTRY_TAGS"] {
54 | SentrySDK.configureScope { scope in
55 | for (key, value) in tags.split(separator: ",").compactMap({ parseCirrusSentryTag($0) }) {
56 | scope.setTag(value: value, key: key)
57 | }
58 | }
59 | }
60 |
61 | // Add commands that are only available on specific macOS versions
62 | if #available(macOS 14, *) {
63 | configuration.subcommands.append(Suspend.self)
64 | }
65 |
66 | // Ensure the default SIGINT handled is disabled,
67 | // otherwise there's a race between two handlers
68 | signal(SIGINT, SIG_IGN);
69 | // Handle cancellation by Ctrl+C ourselves
70 | let task = withUnsafeCurrentTask { $0 }!
71 | let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
72 | sigintSrc.setEventHandler {
73 | task.cancel()
74 | }
75 | sigintSrc.activate()
76 |
77 | // Set line-buffered output for stdout
78 | setlinebuf(stdout)
79 |
80 | // Parse and run command
81 | do {
82 | var command = try parseAsRoot()
83 |
84 | // Run garbage-collection before each command (shouldn't take too long)
85 | if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
86 | do {
87 | try Config().gc()
88 | } catch {
89 | fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
90 | }
91 | }
92 |
93 | if var asyncCommand = command as? AsyncParsableCommand {
94 | try await asyncCommand.run()
95 | } else {
96 | try command.run()
97 | }
98 | } catch {
99 | // Capture the error into Sentry
100 | SentrySDK.capture(error: error)
101 | SentrySDK.flush(timeout: 2.seconds.timeInterval)
102 |
103 | // Handle a non-ArgumentParser's exception that requires a specific exit code to be set
104 | if let errorWithExitCode = error as? HasExitCode {
105 | fputs("\(error)\n", stderr)
106 |
107 | Foundation.exit(errorWithExitCode.exitCode)
108 | }
109 |
110 | // Handle any other exception, including ArgumentParser's ones
111 | exit(withError: error)
112 | }
113 | }
114 |
115 | private static func parseCirrusSentryTag(_ tag: String.SubSequence) -> (String, String)? {
116 | let splits = tag.split(separator: "=", maxSplits: 1)
117 | if splits.count != 2 {
118 | return nil
119 | }
120 |
121 | return (String(splits[0]), String(splits[1]))
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/Sources/tart/Serial.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | func createPTY() -> Int32 {
4 | var tty_fd: Int32 = -1
5 | var sfd: Int32 = -1
6 | var termios_ = termios()
7 | let tty_path = UnsafeMutablePointer.allocate(capacity: 1024)
8 |
9 | var res = openpty(&tty_fd, &sfd, tty_path, nil, nil);
10 | if (res < 0) {
11 | perror("openpty error")
12 | return -1
13 | }
14 |
15 | // close slave file descriptor
16 | close(sfd)
17 |
18 | res = fcntl(tty_fd, F_GETFL)
19 | if (res < 0) {
20 | perror("fcntl F_GETFL error")
21 | return res
22 | }
23 |
24 | // set serial nonblocking
25 | res = fcntl(tty_fd, F_SETFL, res | O_NONBLOCK)
26 | if (res < 0) {
27 | perror("fcntl F_SETFL O_NONBLOCK error")
28 | return res
29 | }
30 |
31 | // set baudrate to 115200
32 | tcgetattr(tty_fd, &termios_)
33 | cfsetispeed(&termios_, speed_t(B115200))
34 | cfsetospeed(&termios_, speed_t(B115200))
35 | if (tcsetattr(tty_fd, TCSANOW, &termios_) != 0) {
36 | perror("tcsetattr error")
37 | return -1
38 | }
39 |
40 | print("Successfully open pty \(String(cString: tty_path))")
41 |
42 | tty_path.deallocate()
43 | return tty_fd
44 | }
45 |
--------------------------------------------------------------------------------
/Sources/tart/URL+AccessDate.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL {
4 | func accessDate() throws -> Date {
5 | let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
6 | return attrs.contentAccessDate!
7 | }
8 |
9 | func updateAccessDate(_ accessDate: Date = Date()) throws {
10 | let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
11 | let modificationDate = attrs.contentAccessDate!
12 |
13 | let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
14 | let ret = utimes(path, times)
15 | if ret != 0 {
16 | throw RuntimeError.FailedToUpdateAccessDate("utimes(2) failed: \(ret.explanation())")
17 | }
18 | }
19 | }
20 |
21 | extension Date {
22 | func asTimeval() -> timeval {
23 | timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/Sources/tart/URL+Prunable.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension URL: Prunable {
4 | var url: URL {
5 | self
6 | }
7 |
8 | func delete() throws {
9 | try FileManager.default.removeItem(at: self)
10 | }
11 |
12 | func sizeBytes() throws -> Int {
13 | try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Sources/tart/Utils.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Collection {
4 | subscript (safe index: Index) -> Element? {
5 | indices.contains(index) ? self[index] : nil
6 | }
7 | }
8 |
9 | func resolveBinaryPath(_ name: String) -> URL? {
10 | guard let path = ProcessInfo.processInfo.environment["PATH"] else {
11 | return nil
12 | }
13 |
14 | for pathComponent in path.split(separator: ":") {
15 | let url = URL(fileURLWithPath: String(pathComponent))
16 | .appendingPathComponent(name, isDirectory: false)
17 |
18 | if FileManager.default.fileExists(atPath: url.path) {
19 | return url
20 | }
21 | }
22 |
23 | return nil
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/tart/VM+Recovery.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Virtualization
3 | import Dynamic
4 |
5 | // Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
6 |
7 | extension VZVirtualMachine {
8 | @MainActor @available(macOS 12, *)
9 | func start(_ recovery: Bool) async throws {
10 | if !recovery {
11 | // just use the regular API
12 | return try await self.start()
13 | }
14 |
15 | // use some private stuff only for recovery
16 | return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
17 | let handler: @convention(block) (_ result: Any?) -> Void = { result in
18 | if let error = result as? Error {
19 | continuation.resume(throwing: error)
20 | } else {
21 | continuation.resume(returning: ())
22 | }
23 | }
24 | // dynamic magic
25 | let options = Dynamic._VZVirtualMachineStartOptions()
26 | options.bootMacOSRecovery = recovery
27 | Dynamic(self)._start(withOptions: options, completionHandler: handler)
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/tart/VMConfig.swift:
--------------------------------------------------------------------------------
1 | import Virtualization
2 |
3 | class LessThanMinimalResourcesError: NSObject, LocalizedError {
4 | var userExplanation: String
5 |
6 | init(_ userExplanation: String) {
7 | self.userExplanation = userExplanation
8 | }
9 |
10 | override var description: String {
11 | get {
12 | "LessThanMinimalResourcesError: \(userExplanation)"
13 | }
14 | }
15 | }
16 |
17 | enum CodingKeys: String, CodingKey {
18 | case version
19 | case os
20 | case arch
21 | case cpuCountMin
22 | case cpuCount
23 | case memorySizeMin
24 | case memorySize
25 | case macAddress
26 | case display
27 | case debugPort
28 |
29 | // macOS-specific keys
30 | case ecid
31 | case hardwareModel
32 | }
33 |
34 | struct VMDisplayConfig: Codable {
35 | var width: Int = 1024
36 | var height: Int = 768
37 | }
38 |
39 | extension VMDisplayConfig: CustomStringConvertible {
40 | var description: String {
41 | "\(width)x\(height)"
42 | }
43 | }
44 |
45 | struct VMConfig: Codable {
46 | var version: Int = 1
47 | var os: OS
48 | var arch: Architecture
49 | var platform: Platform
50 | var cpuCountMin: Int
51 | private(set) var cpuCount: Int
52 | var memorySizeMin: UInt64
53 | private(set) var memorySize: UInt64
54 | var macAddress: VZMACAddress
55 | var display: VMDisplayConfig = VMDisplayConfig()
56 | var debugPort: Int = 8000
57 |
58 | init(
59 | platform: Platform,
60 | cpuCountMin: Int,
61 | memorySizeMin: UInt64,
62 | macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
63 | ) {
64 | self.os = platform.os()
65 | self.arch = CurrentArchitecture()
66 | self.platform = platform
67 | self.macAddress = macAddress
68 | self.cpuCountMin = cpuCountMin
69 | self.memorySizeMin = memorySizeMin
70 | cpuCount = cpuCountMin
71 | memorySize = memorySizeMin
72 | }
73 |
74 | init(fromJSON: Data) throws {
75 | self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
76 | }
77 |
78 | init(fromURL: URL) throws {
79 | self = try Self(fromJSON: try Data(contentsOf: fromURL))
80 | }
81 |
82 | func toJSON() throws -> Data {
83 | try Config.jsonEncoder().encode(self)
84 | }
85 |
86 | func save(toURL: URL) throws {
87 | let encoder = JSONEncoder()
88 | encoder.outputFormatting = .prettyPrinted
89 | try encoder.encode(self).write(to: toURL)
90 | }
91 |
92 | init(from decoder: Decoder) throws {
93 | let container = try decoder.container(keyedBy: CodingKeys.self)
94 |
95 | version = try container.decode(Int.self, forKey: .version)
96 | os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
97 | arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
98 | switch os {
99 | case .darwin:
100 | platform = try Darwin(from: decoder)
101 | case .linux:
102 | if #available(macOS 13, *) {
103 | platform = try Linux(from: decoder)
104 | } else {
105 | throw UnsupportedOSError("Linux VMs", "are")
106 | }
107 | }
108 | cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
109 | cpuCount = try container.decode(Int.self, forKey: .cpuCount)
110 | memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
111 | memorySize = try container.decode(UInt64.self, forKey: .memorySize)
112 |
113 | let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
114 | guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
115 | throw DecodingError.dataCorruptedError(
116 | forKey: .hardwareModel,
117 | in: container,
118 | debugDescription: "failed to initialize VZMacAddress using the provided value")
119 | }
120 | self.macAddress = macAddress
121 |
122 | display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
123 |
124 | debugPort = try container.decode(Int.self, forKey: .debugPort)
125 | }
126 |
127 | func encode(to encoder: Encoder) throws {
128 | var container = encoder.container(keyedBy: CodingKeys.self)
129 |
130 | try container.encode(version, forKey: .version)
131 | try container.encode(os, forKey: .os)
132 | try container.encode(arch, forKey: .arch)
133 | try platform.encode(to: encoder)
134 | try container.encode(cpuCountMin, forKey: .cpuCountMin)
135 | try container.encode(cpuCount, forKey: .cpuCount)
136 | try container.encode(memorySizeMin, forKey: .memorySizeMin)
137 | try container.encode(memorySize, forKey: .memorySize)
138 | try container.encode(macAddress.string, forKey: .macAddress)
139 | try container.encode(display, forKey: .display)
140 | try container.encode(debugPort, forKey: .debugPort)
141 | }
142 |
143 | mutating func setCPU(cpuCount: Int) throws {
144 | if cpuCount < cpuCountMin {
145 | throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
146 | + " at minimum (requested \(cpuCount))")
147 | }
148 |
149 | self.cpuCount = cpuCount
150 | }
151 |
152 | mutating func setMemory(memorySize: UInt64) throws {
153 | if memorySize < memorySizeMin {
154 | throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
155 | + " of memory at minimum (requested \(memorySize))")
156 | }
157 |
158 | self.memorySize = memorySize
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/Sources/tart/VMDirectory+Archive.swift:
--------------------------------------------------------------------------------
1 | import System
2 | import AppleArchive
3 |
4 | fileprivate let permissions = FilePermissions(rawValue: 0o644)
5 |
6 | // Compresses VMDirectory using Apple's proprietary archive format[1] and LZFSE compression,
7 | // which is recommended on Apple platforms[2].
8 | //
9 | // [1]: https://developer.apple.com/documentation/accelerate/compressing_file_system_directories
10 | // [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse
11 | extension VMDirectory {
12 | func exportToArchive(path: String) throws {
13 | guard let fileStream = ArchiveByteStream.fileStream(
14 | path: FilePath(path),
15 | mode: .writeOnly,
16 | options: [.create, .truncate],
17 | permissions: permissions
18 | ) else {
19 | let details = Errno(rawValue: CInt(errno))
20 |
21 | throw RuntimeError.ExportFailed("ArchiveByteStream.fileStream() failed: \(details)")
22 | }
23 | defer {
24 | try? fileStream.close()
25 | }
26 |
27 | guard let compressionStream = ArchiveByteStream.compressionStream(
28 | using: .lzfse,
29 | writingTo: fileStream
30 | ) else {
31 | let details = Errno(rawValue: CInt(errno))
32 |
33 | throw RuntimeError.ExportFailed("ArchiveByteStream.compressionStream() failed: \(details)")
34 | }
35 | defer {
36 | try? compressionStream.close()
37 | }
38 |
39 | guard let encodeStream = ArchiveStream.encodeStream(writingTo: compressionStream) else {
40 | let details = Errno(rawValue: CInt(errno))
41 |
42 | throw RuntimeError.ExportFailed("ArchiveStream.encodeStream() failed: \(details)")
43 | }
44 | defer {
45 | try? encodeStream.close()
46 | }
47 |
48 | guard let keySet = ArchiveHeader.FieldKeySet("TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM") else {
49 | return
50 | }
51 |
52 | try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet)
53 | }
54 |
55 | func importFromArchive(path: String) throws {
56 | guard let fileStream = ArchiveByteStream.fileStream(path: FilePath(path), mode: .readOnly, options: [],
57 | permissions: permissions) else {
58 | let details = Errno(rawValue: CInt(errno))
59 |
60 | throw RuntimeError.ImportFailed("ArchiveByteStream.fileStream() failed: \(details)")
61 | }
62 | defer {
63 | try? fileStream.close()
64 | }
65 |
66 | guard let decompressionStream = ArchiveByteStream.decompressionStream(readingFrom: fileStream) else {
67 | let details = Errno(rawValue: CInt(errno))
68 |
69 | throw RuntimeError.ImportFailed("ArchiveByteStream.decompressionStream() failed: \(details)")
70 | }
71 | defer {
72 | try? decompressionStream.close()
73 | }
74 |
75 | guard let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressionStream) else {
76 | let details = Errno(rawValue: CInt(errno))
77 |
78 | throw RuntimeError.ImportFailed("ArchiveStream.decodeStream() failed: \(details)")
79 | }
80 | defer {
81 | try? decodeStream.close()
82 | }
83 |
84 | guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path),
85 | flags: [.ignoreOperationNotPermitted]) else {
86 | let details = Errno(rawValue: CInt(errno))
87 |
88 | throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)")
89 | }
90 | defer {
91 | try? extractStream.close()
92 | }
93 |
94 | _ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream)
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/Sources/tart/VMDirectory.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Virtualization
3 | import CryptoKit
4 |
5 | struct VMDirectory: Prunable {
6 | var baseURL: URL
7 |
8 | var configURL: URL {
9 | baseURL.appendingPathComponent("config.json")
10 | }
11 | var diskURL: URL {
12 | baseURL.appendingPathComponent("disk.img")
13 | }
14 | var nvramURL: URL {
15 | baseURL.appendingPathComponent("nvram.bin")
16 | }
17 | var romURL: URL {
18 | baseURL.appendingPathComponent("AVPBooter.vmapple2.bin")
19 | }
20 | var stateURL: URL {
21 | baseURL.appendingPathComponent("state.vzvmsave")
22 | }
23 |
24 | var explicitlyPulledMark: URL {
25 | baseURL.appendingPathComponent(".explicitly-pulled")
26 | }
27 |
28 | var name: String {
29 | baseURL.lastPathComponent
30 | }
31 |
32 | var url: URL {
33 | baseURL
34 | }
35 |
36 | func running() throws -> Bool {
37 | // The most common reason why PIDLock() instantiation fails is a race with "tart delete" (ENOENT),
38 | // which is fine to report as "not running".
39 | //
40 | // The other reasons are unlikely and the cost of getting a false positive is way less than
41 | // the cost of crashing with an exception when calling "tart list" on a busy machine, for example.
42 | guard let lock = try? PIDLock(lockURL: configURL) else {
43 | return false
44 | }
45 |
46 | return try lock.pid() != 0
47 | }
48 |
49 | func state() throws -> String {
50 | if try running() {
51 | return "running"
52 | } else if FileManager.default.fileExists(atPath: stateURL.path) {
53 | return "suspended"
54 | } else {
55 | return "stopped"
56 | }
57 | }
58 |
59 | static func temporary() throws -> VMDirectory {
60 | let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
61 | try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
62 |
63 | return VMDirectory(baseURL: tmpDir)
64 | }
65 |
66 | //Create tmp directory with hashing
67 | static func temporaryDeterministic(key: String) throws -> VMDirectory {
68 | let keyData = Data(key.utf8)
69 | let hash = Insecure.MD5.hash(data: keyData)
70 | // Convert hash to string
71 | let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
72 | let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
73 | try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
74 | return VMDirectory(baseURL: tmpDir)
75 | }
76 |
77 | var initialized: Bool {
78 | FileManager.default.fileExists(atPath: configURL.path) &&
79 | FileManager.default.fileExists(atPath: diskURL.path) &&
80 | FileManager.default.fileExists(atPath: nvramURL.path) &&
81 | FileManager.default.fileExists(atPath: romURL.path)
82 | }
83 |
84 | func initialize(overwrite: Bool = false) throws {
85 | if !overwrite && initialized {
86 | throw RuntimeError.VMDirectoryAlreadyInitialized("VM directory is already initialized, preventing overwrite")
87 | }
88 |
89 | try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
90 |
91 | try? FileManager.default.removeItem(at: configURL)
92 | try? FileManager.default.removeItem(at: diskURL)
93 | try? FileManager.default.removeItem(at: nvramURL)
94 | }
95 |
96 | func validate(userFriendlyName: String) throws {
97 | if !FileManager.default.fileExists(atPath: baseURL.path) {
98 | throw RuntimeError.VMDoesNotExist(name: userFriendlyName)
99 | }
100 |
101 | if !initialized {
102 | throw RuntimeError.VMMissingFiles("VM is missing some of its files (\(configURL.lastPathComponent),"
103 | + " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
104 | }
105 | }
106 |
107 | func clone(to: VMDirectory, generateMAC: Bool) throws {
108 | try FileManager.default.copyItem(at: configURL, to: to.configURL)
109 | try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
110 | try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
111 | try? FileManager.default.copyItem(at: stateURL, to: to.stateURL)
112 |
113 | // Re-generate MAC address
114 | if generateMAC {
115 | try to.regenerateMACAddress()
116 | }
117 | }
118 |
119 | func macAddress() throws -> String {
120 | try VMConfig(fromURL: configURL).macAddress.string
121 | }
122 |
123 | func regenerateMACAddress() throws {
124 | var vmConfig = try VMConfig(fromURL: configURL)
125 |
126 | vmConfig.macAddress = VZMACAddress.randomLocallyAdministered()
127 | // cleanup state if any
128 | try? FileManager.default.removeItem(at: stateURL)
129 |
130 | try vmConfig.save(toURL: configURL)
131 | }
132 |
133 | func resizeDisk(_ sizeGB: UInt16) throws {
134 | if !FileManager.default.fileExists(atPath: diskURL.path) {
135 | FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
136 | }
137 | let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
138 | // macOS considers kilo being 1000 and not 1024
139 | try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
140 | try diskFileHandle.close()
141 | }
142 |
143 | func delete() throws {
144 | try FileManager.default.removeItem(at: baseURL)
145 | }
146 |
147 | func accessDate() throws -> Date {
148 | try baseURL.accessDate()
149 | }
150 |
151 | func sizeBytes() throws -> Int {
152 | try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
153 | }
154 |
155 | func sizeGB() throws -> Int {
156 | try sizeBytes() / 1000 / 1000 / 1000
157 | }
158 |
159 | func markExplicitlyPulled() {
160 | FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
161 | }
162 |
163 | func isExplicitlyPulled() -> Bool {
164 | FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/Sources/tart/VMStartOptions.swift:
--------------------------------------------------------------------------------
1 | struct VMStartOptions {
2 | var startUpFromMacOSRecovery: Bool
3 | var forceDFU: Bool
4 | var stopOnFatalError: Bool
5 | var stopOnPanic: Bool
6 | var stopInIBootStage1: Bool
7 | var stopInIBootStage2: Bool
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/tart/VMStorageHelper.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class VMStorageHelper {
4 | static func open(_ name: String) throws -> VMDirectory {
5 | try missingVMWrap(name) {
6 | if let remoteName = try? RemoteName(name) {
7 | return try VMStorageOCI().open(remoteName)
8 | } else {
9 | return try VMStorageLocal().open(name)
10 | }
11 | }
12 | }
13 |
14 | static func delete(_ name: String) throws {
15 | try missingVMWrap(name) {
16 | if let remoteName = try? RemoteName(name) {
17 | try VMStorageOCI().delete(remoteName)
18 | } else {
19 | try VMStorageLocal().delete(name)
20 | }
21 | }
22 | }
23 |
24 | private static func missingVMWrap(_ name: String, closure: () throws -> R) throws -> R {
25 | do {
26 | return try closure()
27 | } catch {
28 | if error.isFileNotFound() {
29 | throw RuntimeError.VMDoesNotExist(name: name)
30 | }
31 |
32 | throw error
33 | }
34 | }
35 | }
36 |
37 | extension Error {
38 | func isFileNotFound() -> Bool {
39 | (self as NSError).code == NSFileReadNoSuchFileError
40 | }
41 | }
42 |
43 | enum RuntimeError : Error {
44 | case VMConfigurationError(_ message: String)
45 | case VMDoesNotExist(name: String)
46 | case VMMissingFiles(_ message: String)
47 | case VMNotRunning(_ message: String)
48 | case VMAlreadyRunning(_ message: String)
49 | case NoIPAddressFound(_ message: String)
50 | case DiskAlreadyInUse(_ message: String)
51 | case FailedToUpdateAccessDate(_ message: String)
52 | case PIDLockFailed(_ message: String)
53 | case FailedToParseRemoteName(_ message: String)
54 | case VMTerminationFailed(_ message: String)
55 | case InvalidCredentials(_ message: String)
56 | case VMDirectoryAlreadyInitialized(_ message: String)
57 | case ExportFailed(_ message: String)
58 | case ImportFailed(_ message: String)
59 | case SoftnetFailed(_ message: String)
60 | case OCIStorageError(_ message: String)
61 | case SuspendFailed(_ message: String)
62 | }
63 |
64 | protocol HasExitCode {
65 | var exitCode: Int32 { get }
66 | }
67 |
68 | extension RuntimeError : CustomStringConvertible {
69 | public var description: String {
70 | switch self {
71 | case .VMConfigurationError(let message):
72 | return message
73 | case .VMDoesNotExist(let name):
74 | return "the specified VM \"\(name)\" does not exist"
75 | case .VMMissingFiles(let message):
76 | return message
77 | case .VMNotRunning(let message):
78 | return message
79 | case .VMAlreadyRunning(let message):
80 | return message
81 | case .NoIPAddressFound(let message):
82 | return message
83 | case .DiskAlreadyInUse(let message):
84 | return message
85 | case .FailedToUpdateAccessDate(let message):
86 | return message
87 | case .PIDLockFailed(let message):
88 | return message
89 | case .FailedToParseRemoteName(let cause):
90 | return "failed to parse remote name: \(cause)"
91 | case .VMTerminationFailed(let message):
92 | return message
93 | case .InvalidCredentials(let message):
94 | return message
95 | case .VMDirectoryAlreadyInitialized(let message):
96 | return message
97 | case .ExportFailed(let message):
98 | return "VM export failed: \(message)"
99 | case .ImportFailed(let message):
100 | return "VM import failed: \(message)"
101 | case .SoftnetFailed(let message):
102 | return "Softnet failed: \(message)"
103 | case .OCIStorageError(let message):
104 | return "OCI storage error: \(message)"
105 | case .SuspendFailed(let message):
106 | return "Failed to suspend the VM: \(message)"
107 | }
108 | }
109 | }
110 |
111 | extension RuntimeError : HasExitCode {
112 | var exitCode: Int32 {
113 | switch self {
114 | case .VMNotRunning:
115 | return 2
116 | case .VMAlreadyRunning:
117 | return 2
118 | default:
119 | return 1
120 | }
121 | }
122 | }
123 |
124 | // Customize error description for Sentry[1]
125 | //
126 | // [1]: https://docs.sentry.io/platforms/apple/guides/ios/usage/#customizing-error-descriptions
127 | extension RuntimeError : CustomNSError {
128 | var errorUserInfo: [String : Any] {
129 | [
130 | NSDebugDescriptionErrorKey: description,
131 | ]
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/Sources/tart/VMStorageLocal.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class VMStorageLocal: PrunableStorage {
4 | let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
5 |
6 | private func vmURL(_ name: String) -> URL {
7 | baseURL.appendingPathComponent(name, isDirectory: true)
8 | }
9 |
10 | func exists(_ name: String) -> Bool {
11 | VMDirectory(baseURL: vmURL(name)).initialized
12 | }
13 |
14 | func open(_ name: String) throws -> VMDirectory {
15 | let vmDir = VMDirectory(baseURL: vmURL(name))
16 |
17 | try vmDir.validate(userFriendlyName: name)
18 |
19 | try vmDir.baseURL.updateAccessDate()
20 |
21 | return vmDir
22 | }
23 |
24 | func create(_ name: String, overwrite: Bool = false) throws -> VMDirectory {
25 | let vmDir = VMDirectory(baseURL: vmURL(name))
26 |
27 | try vmDir.initialize(overwrite: overwrite)
28 |
29 | return vmDir
30 | }
31 |
32 | func move(_ name: String, from: VMDirectory) throws {
33 | _ = try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
34 | _ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
35 | }
36 |
37 | func rename(_ name: String, _ newName: String) throws {
38 | _ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
39 | }
40 |
41 | func delete(_ name: String) throws {
42 | try FileManager.default.removeItem(at: vmURL(name))
43 | }
44 |
45 | func list() throws -> [(String, VMDirectory)] {
46 | do {
47 | return try FileManager.default.contentsOfDirectory(
48 | at: baseURL,
49 | includingPropertiesForKeys: [.isDirectoryKey],
50 | options: .skipsSubdirectoryDescendants).compactMap { url in
51 | let vmDir = VMDirectory(baseURL: url)
52 |
53 | if !vmDir.initialized {
54 | return nil
55 | }
56 |
57 | return (vmDir.name, vmDir)
58 | }
59 | } catch {
60 | if error.isFileNotFound() {
61 | return []
62 | }
63 |
64 | throw error
65 | }
66 | }
67 |
68 | func prunables() throws -> [Prunable] {
69 | try list().map { (_, vmDir) in vmDir }
70 | }
71 |
72 | func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
73 | try list().contains { try $1.macAddress() == macAddress }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/tart/VNC/FullFledgedVNC.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dynamic
3 | import Virtualization
4 |
5 | class FullFledgedVNC: VNC {
6 | let password: String
7 | private let vnc: Dynamic
8 |
9 | init(virtualMachine: VZVirtualMachine) {
10 | password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
11 | let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
12 | vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
13 | securityConfiguration: securityConfiguration)
14 | vnc.virtualMachine = virtualMachine
15 | vnc.start()
16 | }
17 |
18 | func waitForURL() async throws -> URL {
19 | while true {
20 | // Port is 0 shortly after start(),
21 | // but will be initialized later
22 | if let port = vnc.port.asUInt16, port != 0 {
23 | return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
24 | }
25 |
26 | // Wait 50 ms.
27 | try await Task.sleep(nanoseconds: 50_000_000)
28 | }
29 | }
30 |
31 | func stop() throws {
32 | vnc.stop()
33 | }
34 |
35 | deinit {
36 | try? stop()
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/tart/VNC/ScreenSharingVNC.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import Dynamic
3 | import Virtualization
4 |
5 | class ScreenSharingVNC: VNC {
6 | let vmConfig: VMConfig
7 |
8 | init(vmConfig: VMConfig) {
9 | self.vmConfig = vmConfig
10 | }
11 |
12 | func waitForURL() async throws -> URL {
13 | let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
14 | let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
15 |
16 | if let ip = ip {
17 | return URL(string: "vnc://\(ip)")!
18 | }
19 |
20 | throw IPNotFound()
21 | }
22 |
23 | func stop() throws {
24 | // nothing to do
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/tart/VNC/VNC.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol VNC {
4 | func waitForURL() async throws -> URL
5 | func stop() throws
6 | }
7 |
--------------------------------------------------------------------------------
/Tests/TartTests/DigestTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class DigestTests: XCTestCase {
5 | func testEmptyData() throws {
6 | let data = Data("".utf8)
7 |
8 | let digest = Digest()
9 | digest.update(data)
10 | XCTAssertEqual(digest.finalize(), "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
11 |
12 | XCTAssertEqual(Digest.hash(data), "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
13 | }
14 |
15 | func testNonEmptyData() throws {
16 | let data = Data("The quick brown fox jumps over the lazy dog".utf8)
17 |
18 | let digest = Digest()
19 | digest.update(data)
20 | XCTAssertEqual(digest.finalize(), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
21 |
22 | XCTAssertEqual(Digest.hash(data), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Tests/TartTests/DirecotryShareTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class DirectoryShareTests: XCTestCase {
5 | func testNamedParsing() throws {
6 | let share = try DirectoryShare(parseFrom: "build:/Users/admin/build")
7 | XCTAssertEqual(share.name, "build")
8 | XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
9 | XCTAssertFalse(share.readOnly)
10 | }
11 |
12 | func testNamedReadOnlyParsing() throws {
13 | let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro")
14 | XCTAssertEqual(share.name, "build")
15 | XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
16 | XCTAssertTrue(share.readOnly)
17 | }
18 |
19 | func testOptionalNameParsing() throws {
20 | let share = try DirectoryShare(parseFrom: "/Users/admin/build")
21 | XCTAssertNil(share.name)
22 | XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
23 | XCTAssertFalse(share.readOnly)
24 | }
25 |
26 | func testOptionalNameReadOnlyParsing() throws {
27 | let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro")
28 | XCTAssertNil(share.name)
29 | XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build"))
30 | XCTAssertTrue(share.readOnly)
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Tests/TartTests/FileLockTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class FileLockTests: XCTestCase {
5 | func testSimple() throws {
6 | // Create a temporary file that will be used as a lock
7 | let url = temporaryFile()
8 |
9 | // Make sure this file can be locked and unlocked
10 | let lock = try FileLock(lockURL: url)
11 | try lock.lock()
12 | try lock.unlock()
13 | }
14 |
15 | func testDoubleLockResultsInError() throws {
16 | // Create a temporary file that will be used as a lock
17 | let url = temporaryFile()
18 |
19 | // Create two locks on a same file and ensure one of them fails
20 | let firstLock = try FileLock(lockURL: url)
21 | try firstLock.lock()
22 |
23 | let secondLock = try! FileLock(lockURL: url)
24 | XCTAssertFalse(try secondLock.trylock())
25 | }
26 |
27 | private func temporaryFile() -> URL {
28 | let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
29 |
30 | FileManager.default.createFile(atPath: url.path, contents: nil)
31 |
32 | return url
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Tests/TartTests/MACAddressResolverTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import Network
3 | @testable import tart
4 |
5 | final class MACAddressResolverTests: XCTestCase {
6 | func testSingleEntry() throws {
7 | let leases = try Leases("""
8 | {
9 | ip_address=1.2.3.4
10 | hw_address=1,00:11:22:33:44:55
11 | }
12 | """)
13 |
14 | XCTAssertEqual(IPv4Address("1.2.3.4"),
15 | try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
16 | }
17 |
18 | func testMultipleEntries() throws {
19 | let leases = try Leases("""
20 | {
21 | ip_address=1.2.3.4
22 | hw_address=1,00:11:22:33:44:55
23 | }
24 | {
25 | ip_address=5.6.7.8
26 | hw_address=1,AA:BB:CC:DD:EE:FF
27 | }
28 | """)
29 |
30 | XCTAssertEqual(IPv4Address("1.2.3.4"),
31 | try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
32 | XCTAssertEqual(IPv4Address("5.6.7.8"),
33 | try leases.ResolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Tests/TartTests/RegistryTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class RegistryTests: XCTestCase {
5 | var registryRunner: RegistryRunner?
6 |
7 | override func setUp() async throws {
8 | try await super.setUp()
9 |
10 | do {
11 | registryRunner = try await RegistryRunner()
12 | } catch {
13 | try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
14 | }
15 | }
16 |
17 | override func tearDown() async throws {
18 | try await super.tearDown()
19 |
20 | registryRunner = nil
21 | }
22 |
23 | var registry: Registry {
24 | registryRunner!.registry
25 | }
26 |
27 | func testPushPullBlobSmall() async throws {
28 | // Generate a simple blob
29 | let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
30 |
31 | // Push it
32 | let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
33 | XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
34 |
35 | // Pull it
36 | var pulledBlob = Data()
37 | try await registry.pullBlob(pushedBlobDigest) { data in
38 | pulledBlob.append(data)
39 | }
40 |
41 | // Ensure that both blobs are identical
42 | XCTAssertEqual(pushedBlob, pulledBlob)
43 | }
44 |
45 | func testPushPullBlobHugeInChunks() async throws {
46 | // Generate a large enough blob
47 | let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
48 | let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
49 |
50 | // Push it
51 | let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
52 |
53 | // Pull it
54 | var pulledLargeBlob = Data()
55 | try await registry.pullBlob(largeBlobDigest) { data in
56 | pulledLargeBlob.append(data)
57 | }
58 |
59 | // Ensure that both blobs are identical
60 | XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
61 | }
62 |
63 | func testPushPullManifest() async throws {
64 | // Craft a basic config
65 | let configData = try OCIConfig().toJSON()
66 | let configDigest = try await registry.pushBlob(fromData: configData)
67 |
68 | // Craft a basic layer
69 | let layerData = Data("doesn't matter".utf8)
70 | let layerDigest = try await registry.pushBlob(fromData: layerData)
71 |
72 | // Craft a basic manifest and push it
73 | let manifest = OCIManifest(
74 | config: OCIManifestConfig(size: configData.count, digest: configDigest),
75 | layers: [
76 | OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
77 | ]
78 | )
79 | let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
80 |
81 | // Ensure that the manifest pulled by tag matches with the one pushed above
82 | let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
83 | XCTAssertEqual(manifest, pulledByTagManifest)
84 |
85 | // Ensure that the manifest pulled by digest matches with the one pushed above
86 | let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
87 | XCTAssertEqual(manifest, pulledByDigestManifest)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/Tests/TartTests/RemoteNameTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class RemoteNameTests: XCTestCase {
5 | func testTag() throws {
6 | let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "latest"))
7 |
8 | XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
9 | }
10 |
11 | func testComplexTag() throws {
12 | let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
13 |
14 | XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
15 | }
16 |
17 | func testDigest() throws {
18 | let expectedRemoteName = RemoteName(
19 | host: "ghcr.io",
20 | namespace: "a/b",
21 | reference: Reference(digest: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
22 | )
23 |
24 | XCTAssertEqual(expectedRemoteName,
25 | try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
26 | }
27 |
28 | func testASCIIOnly() throws {
29 | // Only ASCII letters are supported
30 | XCTAssertEqual(try? RemoteName("touché.fr/a/b:latest"), nil)
31 | XCTAssertEqual(try? RemoteName("ghcr.io/tou/ché:latest"), nil)
32 | XCTAssertEqual(try? RemoteName("ghcr.io/a/b:touché"), nil)
33 | }
34 |
35 | func testLocal() throws {
36 | // Local image names (those that don't include a registry) are not supported
37 | XCTAssertEqual(try? RemoteName("debian:latest"), nil)
38 | }
39 |
40 | func testPort() throws {
41 | // Port is included in host
42 | XCTAssertEqual(try RemoteName("127.0.0.1:8080/a/b").host, "127.0.0.1:8080")
43 |
44 | // Port must be specified when ":" is used
45 | XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil)
46 | }
47 |
48 | func testNoPathTraversal() throws {
49 | XCTAssertEqual(try? RemoteName("ghcr.io/a/../b/c:latest"), nil)
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/Tests/TartTests/TokenResponseTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class TokenResponseTests: XCTestCase {
5 | func testBasic() throws {
6 | let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
7 | let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
8 |
9 | XCTAssertEqual(tokenResponse.token, "some token")
10 |
11 | let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
12 | XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
13 |
14 | XCTAssertTrue(tokenResponse.isValid())
15 | }
16 |
17 | func testExpirationBasic() throws {
18 | let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
19 | let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
20 |
21 | XCTAssertEqual(tokenResponse.expiresIn, 2)
22 |
23 | let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
24 | XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
25 |
26 | XCTAssertTrue(tokenResponse.isValid())
27 | _ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
28 | XCTAssertFalse(tokenResponse.isValid())
29 | }
30 |
31 | func testExpirationWithIssuedAt() throws {
32 | let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":3600,\"issued_at\":\"1970-01-01T00:00:00Z\"}".utf8)
33 | let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
34 |
35 | XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
36 | XCTAssertFalse(tokenResponse.isValid())
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Tests/TartTests/URLAbsolutizationTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class URLAbsolutizationTets: XCTestCase {
5 | func testNeedsAbsolutization() throws {
6 | let url = URL(string: "/v2/some/path?some=query")!
7 | .absolutize(URL(string: "https://example.com/v2/")!)
8 |
9 | XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
10 | }
11 |
12 | func testDoesntNeedAbsolutization() throws {
13 | let url = URL(string: "https://example.org/v2/some/path?some=query")!
14 | .absolutize(URL(string: "https://example.com/v2/")!)
15 |
16 | XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/TartTests/URLAccessDateTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class URLAccessDateTests: XCTestCase {
5 | func testGetAndSetAccessTime() throws {
6 | // Create a temporary file
7 | let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
8 | var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
9 | FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
10 |
11 | // Ensure it's access date is different than our desired access date
12 | let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
13 | XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
14 |
15 | // Set our desired access date for a file
16 | try tmpFile.updateAccessDate(arbitraryDate)
17 |
18 | // Ensure the access date has changed to our value
19 | tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
20 | XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Tests/TartTests/Util/RegistryRunner.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | @testable import tart
3 |
4 | enum RegistryRunnerError: Error {
5 | case DockerFailed(exitCode: Int32)
6 | }
7 |
8 | class RegistryRunner {
9 | let containerID: String
10 | let registry: Registry
11 |
12 | static func dockerCmd(_ arguments: String...) throws -> String {
13 | let stdoutPipe = Pipe()
14 |
15 | let proc = Process()
16 | proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
17 | proc.arguments = arguments
18 | proc.standardOutput = stdoutPipe
19 | try proc.run()
20 |
21 | let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
22 |
23 | proc.waitUntilExit()
24 |
25 | if proc.terminationStatus != 0 {
26 | throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
27 | }
28 |
29 | return String(data: stdoutData, encoding: .utf8) ?? ""
30 | }
31 |
32 | init() async throws {
33 | // Start container
34 | let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
35 | .trimmingCharacters(in: CharacterSet.newlines)
36 | containerID = container
37 |
38 | // Get forwarded port
39 | let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
40 | .trimmingCharacters(in: CharacterSet.newlines)
41 |
42 | registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
43 | namespace: "vm-image")
44 |
45 | // Wait for the Docker Registry to start
46 | while ((try? await registry.ping()) == nil) {
47 | try await Task.sleep(nanoseconds: 100_000_000)
48 | }
49 | }
50 |
51 | deinit {
52 | _ = try! Self.dockerCmd("kill", containerID)
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Tests/TartTests/WWWAuthenticateTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import tart
3 |
4 | final class WWWAuthenticateTests: XCTestCase {
5 | func testExample() throws {
6 | // Test example from Token Authentication Specification[1]
7 | //
8 | // [1]: https://docs.docker.com/registry/spec/auth/token/
9 | let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: "Bearer realm=\"https://auth.docker.io/token\",service=\"registry.docker.io\",scope=\"repository:samalba/my-app:pull,push\"")
10 |
11 | XCTAssertEqual("Bearer", wwwAuthenticate.scheme)
12 | XCTAssertEqual([
13 | "realm": "https://auth.docker.io/token",
14 | "service": "registry.docker.io",
15 | "scope": "repository:samalba/my-app:pull,push",
16 | ], wwwAuthenticate.kvs)
17 | }
18 |
19 | func testBasic() throws {
20 | let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: "Bearer a=b,c=\"d\"")
21 |
22 | XCTAssertEqual("Bearer", wwwAuthenticate.scheme)
23 | XCTAssertEqual(["a": "b", "c": "d"], wwwAuthenticate.kvs)
24 | }
25 |
26 | func testIncompleteHeader() throws {
27 | XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Whatever")) {
28 | XCTAssertTrue($0 is RegistryError)
29 | }
30 |
31 | XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Bearer ")) {
32 | XCTAssertTrue($0 is RegistryError)
33 | }
34 | }
35 |
36 | func testIncompleteDirective() throws {
37 | XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Bearer whatever")) {
38 | XCTAssertTrue($0 is RegistryError)
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | tart.run
2 | www.tart.run
3 |
--------------------------------------------------------------------------------
/docs/assets/TartLicenseSubscription.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/TartLicenseSubscription.pdf
--------------------------------------------------------------------------------
/docs/assets/animations/Orchard.lottie:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/animations/Orchard.lottie
--------------------------------------------------------------------------------
/docs/assets/animations/TartLogo.lottie:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/animations/TartLogo.lottie
--------------------------------------------------------------------------------
/docs/assets/images/CirrusLogo.svg:
--------------------------------------------------------------------------------
1 |
2 |
16 |
--------------------------------------------------------------------------------
/docs/assets/images/TartCirrusCLI.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/TartCirrusCLI.gif
--------------------------------------------------------------------------------
/docs/assets/images/TartGHARunners.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/TartGHARunners.png
--------------------------------------------------------------------------------
/docs/assets/images/TartLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/TartLogo.png
--------------------------------------------------------------------------------
/docs/assets/images/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/favicon.ico
--------------------------------------------------------------------------------
/docs/assets/images/orchard-port-forwarding-api.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/orchard-port-forwarding-api.png
--------------------------------------------------------------------------------
/docs/assets/images/spotlight/github-actions-runners.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/spotlight/github-actions-runners.webp
--------------------------------------------------------------------------------
/docs/assets/images/spotlight/supported-registries.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/spotlight/supported-registries.webp
--------------------------------------------------------------------------------
/docs/assets/images/spotlight/virtualization-framework.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/spotlight/virtualization-framework.webp
--------------------------------------------------------------------------------
/docs/assets/images/users/max-lapides.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/users/max-lapides.webp
--------------------------------------------------------------------------------
/docs/assets/images/users/mikhail-tokarev.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/users/mikhail-tokarev.webp
--------------------------------------------------------------------------------
/docs/assets/images/users/seb-jachec.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nick-botticelli/super-tart/9fd83841a65f384e46c5b6f3f4bd0aa96076dec3/docs/assets/images/users/seb-jachec.webp
--------------------------------------------------------------------------------
/docs/blog/.authors.yml:
--------------------------------------------------------------------------------
1 | edigaryev:
2 | name: Nikolay Edigaryev
3 | description: Creator
4 | avatar: https://github.com/edigaryev.png
5 | fkorotkov:
6 | name: Fedor Korotkov
7 | description: Creator
8 | avatar: https://github.com/fkorotkov.png
9 |
--------------------------------------------------------------------------------
/docs/blog/index.md:
--------------------------------------------------------------------------------
1 | # Blog
2 |
--------------------------------------------------------------------------------
/docs/blog/posts/2023-04-25-orchard-ga.md:
--------------------------------------------------------------------------------
1 | ---
2 | draft: false
3 | date: 2023-04-25
4 | search:
5 | exclude: true
6 | authors:
7 | - fkorotkov
8 | categories:
9 | - announcement
10 | - orchard
11 | ---
12 |
13 | # Announcing Orchard orchestration for managing macOS virtual machines at scale
14 |
15 | Today we are happy to announce general availability of Orchard – our new orchestrator to manage Tart virtual machines at scale.
16 | In this post we’ll cover the motivation behind creating yet another orchestrator and why we didn’t go with Kubernetes or Nomad integration.
17 |
18 | ## What problem are we trying to solve?
19 |
20 | After releasing Tart we pretty quickly started getting requests about managing macOS virtual machines on a cluster of
21 | Apple Silicon machines rather than just a single host which only allows a maximum of two virtual machines at a time.
22 | By the end of 2022 the requests reached a tipping point, and we started planning.
23 |
24 |
25 |
26 | First, we established some constraints about the end users and potential workload our solution should handle.
27 | Running macOS or Linux virtual machines on Apple Silicon is a very niche use case. These VMs are either used in
28 | automation solutions like CI/CD or for managing remote desktop environments. In this case **we are aiming to manage
29 | only thousands of virtual machines and not millions**.
30 |
31 | Second, **operators of such solutions won’t have experience of operating Kubernetes or Nomad**. Operators will most likely
32 | come with experience of using such systems but not managing them. And again, having built-in things like RBAC and
33 | ability to scale to millions were appealing but it seemed like it would be a solution for a few rather than a solution
34 | for everybody to use. Additionally Orchard should provide **first class support for accessing virtual machines over SSH/VNC**
35 | and support script execution.
36 |
37 | By that time, the idea of building a simple opinionated orchestrator got more and more appealing. Plus we kind of already did it
38 | for [Cirrus CI’s persistent workers](https://cirrus-ci.org/guide/persistent-workers/) feature.
39 |
40 | ## Technical constraints
41 |
42 | With the UX constraints and expectations in place we started thinking about architecture for the orchestrator that we
43 | started calling **Orchard**.
44 |
45 |
46 |
53 |
54 | Since Orchard will manage a maximum of a couple thousands virtual machines and not millions we **decided to not think much
55 | about horizontal scalability.** Just a single instance of Orchard controller should be enough if it can restart quickly and
56 | persist state between restarts.
57 |
58 | **Orchard should be secure by default**. All the communication between a controller and workers should be secure.
59 | All external API requests to Orchard controller should be authorized.
60 |
61 | During development it’s crucial to have a quick feedback cycle. **It should be extremely easy to run Orchard in development**.
62 | Configuring a production cluster should be also easy for novice operators.
63 |
64 | ## High-level implementation details
65 |
66 | Cirrus Labs started as a predominantly Kotlin shop with a little Go. But over the years we gradually moved a lot of things to Go.
67 | We love the expressibility of Kotlin as a language but the ecosystem for writing system utilities and services is superb in Go.
68 |
69 | Orchard is a single Go project that implements both controller server interface and worker client logic in a single repository.
70 | This simplifies code sharing and testability of the both components and allows to change them in a single pull request.
71 |
72 | Another benefit is that Orchard can be distributed as a single binary. We intend to run Orchard controller on a single host.
73 | Data model for the orchestration didn’t look complex as well. These observations lead us to exploring the use of an embedded database.
74 | Just imagine! **Orchard can be distributed as a single binary with no external dependencies on any database or runtime!**
75 |
76 | And we did exactly that! Orchard is distributed as a single binary that can be run in “controller” mode on a Linux/macOS host and
77 | in “worker” mode on macOS hosts. Orchard controller is using extremely fast [BadgerDB](https://dgraph.io/docs/badger/) key-value storage to persist data.
78 |
79 | ## Conclusion
80 |
81 | Please give [Orchard](https://github.com/cirruslabs/orchard) a try! To run it locally in development mode on any Apple Silicon device
82 | please run the following command:
83 |
84 | ```bash
85 | brew install cirruslabs/cli/orchard
86 | orchard dev
87 | ```
88 |
89 | This will launch a development cluster with a single worker on your machine. Refer to [Orchard documentation](https://github.com/cirruslabs/orchard#creating-virtual-machines)
90 | on how to create your first virtual machine and access it.
91 |
92 | In a [separate blog post](/blog/2023/04/28/ssh-over-grpc-or-how-orchard-simplifies-accessing-vms-in-private-networks/)
93 | we’ll cover how Orchard implements seamless SSH access over a gRPC connection. Stay tuned and please don’t hesitate to
94 | [reach out](https://github.com/cirruslabs/orchard/discussions/landing)!
95 |
--------------------------------------------------------------------------------
/docs/faq.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - navigation
4 | ---
5 |
6 | ## How Tart is different from Anka?
7 |
8 | Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
9 | or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
10 |
11 | Instead of Anka Registry, Tart can work with any OCI-compatible container registry. This provides a much more consistent
12 | and scalable experience for distributing virtual machines.
13 |
14 | Tart doesn't yet have an analogue of Anka Controller for managing long living VMs but [soon will be](https://github.com/cirruslabs/tart/issues/372).
15 |
16 | ## VM location on disk
17 |
18 | Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
19 | Remote images are pulled into `~/.tart/cache/OCIs/`.
20 |
21 | ## Nested virtualization support?
22 |
23 | Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
24 | doesn't support nested virtualization.
25 |
26 | ## Connecting to a service running on host
27 |
28 | To connect from within a virtual machine to a service running on the host machine
29 | please first make sure that the service is binded to `0.0.0.0`.
30 |
31 | Then from within a virtual machine you can access the service using the router's IP address that you can get either from `Preferences -> Network`
32 | or by running the following command in the Terminal:
33 |
34 | ```shell
35 | netstat -nr | grep default | head -n 1 | awk '{print $2}'
36 | ```
37 |
38 | Note: that accessing host is only possible with the default NAT network. If you are running your virtual machines with
39 | [Softnet](https://github.com/cirruslabs/softnet) (via `tart run --net-softnet )`, then the network isolation
40 | is stricter and it's not only possible to access the host.
41 |
42 | ## Changing the default NAT subnet
43 |
44 | To change the default network to `192.168.77.1`:
45 |
46 | ```shell
47 | sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
48 | ```
49 |
50 | 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.
51 |
52 | 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:
53 |
54 | ```shell
55 | sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
56 | ```
57 |
58 | ## Changing the default DHCP lease time
59 |
60 | By default, the built-in macOS DHCP server allocates IP-addresses to the VMs for the duration of 86,400 seconds (one day), which may easily cause DHCP exhaustion if you run more than ~253 VMs per day, or in other words, more than one VM every ~6 minutes.
61 |
62 | This issue is worked around automatically [when using Softnet](http://github.com/cirruslabs/softnet), however, if you don't use or can't use it, the following command will reduce the lease time from the default 86,400 seconds (one day) to 600 seconds (10 minutes):
63 |
64 | ```shell
65 | sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
66 | ```
67 |
68 | Note that this tweak persists across reboots, so normally you'll only need to do it once per new host.
69 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | template: overrides/home.html
3 | title: Tart
4 | ---
5 |
--------------------------------------------------------------------------------
/docs/integrations/cirrus-cli.md:
--------------------------------------------------------------------------------
1 | # Cirrus CLI
2 |
3 | Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
4 | also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
5 | one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
6 | We built Cirrus CLI to solve "But it works on my machine!" problem.
7 |
8 | Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
9 | will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
10 |
11 | ```yaml
12 | task:
13 | name: hello
14 | macos_instance:
15 | # can be a remote or a local virtual machine
16 | image: ghcr.io/cirruslabs/macos-monterey-base:latest
17 | hello_script:
18 | - echo "Hello from within a Tart VM!"
19 | - echo "Here is my CPU info:"
20 | - sysctl -n machdep.cpu.brand_string
21 | - sleep 15
22 | ```
23 |
24 | Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
25 |
26 | ```bash
27 | brew install cirruslabs/cli/cirrus
28 | cirrus run
29 | ```
30 |
31 | 
32 |
33 | [Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
34 | config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
35 |
36 | **Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
37 |
38 | ## Retrieving artifacts from within Tart VMs
39 |
40 | In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
41 | For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
42 | exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
43 |
44 | ```yaml
45 | task:
46 | name: Build
47 | macos_instance:
48 | image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
49 | build_script: swift build --product tart
50 | binary_artifacts:
51 | path: .build/debug/tart
52 | ```
53 |
54 | Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
55 |
56 | ```bash
57 | cirrus run --artifacts-dir artifacts
58 | ```
59 |
60 | Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
61 | For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
62 |
--------------------------------------------------------------------------------
/docs/integrations/github-actions.md:
--------------------------------------------------------------------------------
1 | # GitHub Actions
2 |
3 | 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.
4 | For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
5 | *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.
6 |
7 | ## Testimonials from customers
8 |
9 | Sebastian Jachec, Mobile Engineer at [Daybridge](https://www.daybridge.com/).
10 |
11 | > It’s been plain-sailing with the Cirrus Runners — they’ve been great! They’re consistently 60+% faster on workflows that we previously used Github Actions’ macOS runners for.
12 |
13 | Max Lapides, Senior Mobile Engineer at [Tonal](https://www.tonal.com/).
14 |
15 | > Previously, we were using the GitHub‑hosted macOS runners and our iOS build took ~30 minutes. Now with Cirrus Runners, the iOS build only takes ~12 minutes. That’s a huge boost to our productivity, and for only $150/month per runner it is much less expensive too.
16 |
17 |
18 | ## Configuring Cirrus Runners
19 |
20 | Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
21 | After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
22 |
23 | ```yaml
24 | name: Test Suite
25 | jobs:
26 | test:
27 | runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
28 | ```
29 |
30 | When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations//settings/actions/runners`.
31 | Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
32 |
33 | 
34 |
--------------------------------------------------------------------------------
/docs/integrations/gitlab-runner.md:
--------------------------------------------------------------------------------
1 | # GitLab Runner Executor
2 |
3 | It is possible to run GitLab jobs in isolated ephemeral Tart Virtual Machines via [Tart Executor](https://github.com/cirruslabs/gitlab-tart-executor).
4 | Tart Executor utilizes [custom executor](https://docs.gitlab.com/runner/executors/custom.html) feature of GitLab Runner.
5 |
6 | # Basic Configuration
7 |
8 | Configuring Tart Executor for GitLab Runner is as simple as installing `gitlab-tart-executor` binary from Homebrew:
9 |
10 | ```bash
11 | brew install cirruslabs/cli/gitlab-tart-executor
12 | ```
13 |
14 | And updating configuration of your self-hosted GitLab Runner to use `gitlab-tart-executor` binary:
15 |
16 | ```toml
17 | concurrent = 2
18 |
19 | [[runners]]
20 | # ...
21 | executor = "custom"
22 | builds_dir = "/Users/admin/builds" # directory inside the
23 | cache_dir = "/Users/admin/cache"
24 | [runners.feature_flags]
25 | FF_RESOLVE_FULL_TLS_CHAIN = false
26 | [runners.custom]
27 | prepare_exec = "gitlab-tart-executor"
28 | prepare_args = ["prepare"]
29 | run_exec = "gitlab-tart-executor"
30 | run_args = ["run"]
31 | cleanup_exec = "gitlab-tart-executor"
32 | cleanup_args = ["cleanup"]
33 | ```
34 |
35 | Now you can use Tart Images in your `.gitlab-ci.yml`:
36 |
37 | ```yaml
38 | # You can use any remote Tart Image.
39 | # Tart Executor will pull it from the registry and use it for creating ephemeral VMs.
40 | image: ghcr.io/cirruslabs/macos-ventura-base:latest
41 |
42 | test:
43 | tags:
44 | - tart-installed # in case you tagged runners with Tart Executor installed
45 | script:
46 | - uname -a
47 | ```
48 |
49 | For more advanced configuration please refer to [GitLab Tart Executor repository](https://github.com/cirruslabs/gitlab-tart-executor).
50 |
--------------------------------------------------------------------------------
/docs/integrations/vm-management.md:
--------------------------------------------------------------------------------
1 | # Managing Virtual Machine
2 |
3 | ## Creating from scratch
4 |
5 | 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.
6 | 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.
7 |
8 | ### Creating a macOS VM image from scratch
9 |
10 | Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
11 | use `latest` instead of a path to `*.ipsw` to download the latest available version:
12 |
13 | ```bash
14 | tart create --from-ipsw=latest monterey-vanilla
15 | tart run monterey-vanilla
16 | ```
17 |
18 | After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
19 |
20 | 1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
21 | 2. Allow SSH. Sharing -> Remote Login
22 | 3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
23 | 4. Disable Screen Saver.
24 | 5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
25 |
26 | ### Creating a Linux VM image from scratch
27 |
28 | Linux VMs are supported on hosts running macOS 13.0 (Ventura) or newer.
29 |
30 | ```bash
31 | # Create a bare VM
32 | tart create --linux ubuntu
33 |
34 | # Install Ubuntu
35 | tart run --disk focal-desktop-arm64.iso ubuntu
36 |
37 | # Run VM
38 | tart run ubuntu
39 | ```
40 |
41 | After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
42 |
43 | ```bash
44 | sudo apt update
45 | sudo apt install -y openssh-server
46 | sudo ufw allow ssh
47 | ```
48 |
49 | ## Configuring a VM
50 |
51 | 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.
52 | Please refer to `tart set --help` for additional details.
53 |
54 | ## Building with Packer
55 |
56 | Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
57 | Here is an example of a template to build `monterey-base` local image based of a remote image:
58 |
59 | ```hcl
60 | packer {
61 | required_plugins {
62 | tart = {
63 | version = ">= 0.5.3"
64 | source = "github.com/cirruslabs/tart"
65 | }
66 | }
67 | }
68 |
69 | source "tart-cli" "tart" {
70 | vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
71 | vm_name = "my-custom-ventura"
72 | cpu_count = 4
73 | memory_gb = 8
74 | disk_size_gb = 70
75 | ssh_password = "admin"
76 | ssh_timeout = "120s"
77 | ssh_username = "admin"
78 | }
79 |
80 | build {
81 | sources = ["source.tart-cli.tart"]
82 |
83 | provisioner "shell" {
84 | inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
85 | }
86 |
87 | # more provisioners
88 | }
89 | ```
90 |
91 | Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
92 |
93 | ## Working with a Remote OCI Container Registry
94 |
95 | For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
96 |
97 | ### Registry Authorization
98 |
99 | First, you need to log in and save credential for `acme.io` host via `tart login` command:
100 |
101 | ```bash
102 | tart login acme.io
103 | ```
104 |
105 | Credentials are securely stored in Keychain.
106 |
107 | In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
108 | if defined in `~/.docker/config.json`.
109 |
110 | Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
111 | for all registries which might useful for integrating with your CI's secret management.
112 |
113 | ### Pushing a Local Image
114 |
115 | Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
116 |
117 | ```bash
118 | tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
119 | ```
120 |
121 | ### Pulling a Remote Image
122 |
123 | You can either pull an image:
124 |
125 | ```bash
126 | tart pull acme.io/remoteorg/name:latest
127 | ```
128 |
129 | ...or instantiate a VM from a remote image:
130 |
131 | ```bash
132 | tart clone acme.io/remoteorg/name:latest my-local-vm-name
133 | ```
134 |
135 | This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
136 |
--------------------------------------------------------------------------------
/docs/quick-start.md:
--------------------------------------------------------------------------------
1 | ---
2 | hide:
3 | - navigation
4 | ---
5 |
6 | Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
7 |
8 | ```bash
9 | brew install cirruslabs/cli/tart
10 | tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
11 | tart run ventura-base
12 | ```
13 |
14 | ??? info "Manual installation from a release archive"
15 | It's also possible to manually install `tart` binary from the latest released archive:
16 |
17 | ```bash
18 | curl -LO https://github.com/cirruslabs/tart/releases/latest/download/tart.tar.gz
19 | tar -xzvf tart.tar.gz
20 | ./tart.app/Contents/MacOS/tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
21 | ./tart.app/Contents/MacOS/tart run ventura-base
22 | ```
23 |
24 | Please note that `./tart.app/Contents/MacOS/tart` binary is required to be used in order to trick macOS
25 | to pick `tart.app/Contents/embedded.provisionprofile` for elevated privileges that Tart needs.
26 |
27 |
28 |
29 |
30 |
31 | ## SSH access
32 |
33 | If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
34 |
35 | ```bash
36 | ssh admin@$(tart ip ventura-base)
37 | ```
38 |
39 | !!! tip "Running scripts inside Tart virtual machines"
40 | We recommend using [Cirrus CLI](integrations/cirrus-cli.md) to run scripts and/or retrieve artifacts
41 | from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
42 |
43 | ```bash
44 | brew install sshpass
45 | sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) "uname -a"
46 | sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
47 | ```
48 |
49 | ## Mounting directories
50 |
51 | To mount a directory, run the VM with the `--dir` argument:
52 |
53 | ```bash
54 | tart run --dir=project:~/src/project vm
55 | ```
56 |
57 | Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
58 |
59 | It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
60 |
61 | ```bash
62 | tart run --dir=project:~/src/project:ro vm
63 | ```
64 |
65 | To mount multiple directories, repeat the `--dir` argument for each directory:
66 |
67 | ```bash
68 | tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
69 | ```
70 |
71 | Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
72 |
73 | Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
74 |
75 | ### Accessing mounted directories in macOS guests
76 |
77 | All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
78 |
79 | The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
80 |
81 | Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
82 |
83 | ??? tip "Changing mount location"
84 | It is possible to remount the directories after a virtual machine is started by running the following commands:
85 |
86 | ```bash
87 | sudo umount "/Volumes/My Shared Files"
88 | mkdir ~/workspace
89 | mount_virtiofs com.apple.virtio-fs.automount ~/workspace
90 | ```
91 |
92 | After running the above commands the direcory will be available at `~/workspace/project`
93 |
94 | ### Accessing mounted directories in Linux guests
95 |
96 | To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
97 |
98 | ```bash
99 | mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
100 | ```
101 |
102 | The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
103 |
104 |
--------------------------------------------------------------------------------
/docs/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: *
3 | Disallow:
4 | Sitemap: https://tart.run/sitemap.xml
5 |
--------------------------------------------------------------------------------
/docs/stylesheets/extra.css:
--------------------------------------------------------------------------------
1 | /* Remove default title on the page */
2 | .md-content__inner h1:first-child {
3 | display: none;
4 | }
5 |
6 | /* Adjust to 2px to align with the title */
7 | .md-logo {
8 | padding-top: 6px;
9 | }
10 |
11 | .btn {
12 | border: none;
13 | padding: 14px 28px;
14 | cursor: pointer;
15 | display: inline-block;
16 |
17 | background: #009688;
18 | color: white;
19 | }
20 |
21 | .btn:hover {
22 | background: #00bfa5;
23 | color: white;
24 | }
25 |
26 | .center {
27 | display: block;
28 | margin-left: auto;
29 | margin-right: auto;
30 | }
31 |
32 | .text-center {
33 | text-align: center;
34 | }
35 |
--------------------------------------------------------------------------------
/gon.hcl:
--------------------------------------------------------------------------------
1 | source = [".build/arm64-apple-macosx/release/tart"]
2 | bundle_id = "com.github.cirruslabs.tart"
3 |
4 | apple_id {
5 | username = "hello@cirruslabs.org"
6 | password = "@env:AC_PASSWORD"
7 | }
8 |
9 | sign {
10 | application_identity = "Developer ID Application: Cirrus Labs, Inc."
11 | entitlements_file = "Resources/tart-prod.entitlements"
12 | }
13 |
--------------------------------------------------------------------------------
/integration-tests/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from tart import Tart
4 | from docker_registry import DockerRegistry
5 |
6 |
7 | @pytest.fixture(scope="class")
8 | def tart():
9 | with Tart() as tart:
10 | yield tart
11 |
12 |
13 | @pytest.fixture(scope="class")
14 | def docker_registry():
15 | with DockerRegistry() as docker_registry:
16 | yield docker_registry
17 |
--------------------------------------------------------------------------------
/integration-tests/docker_registry.py:
--------------------------------------------------------------------------------
1 | import requests
2 |
3 | from testcontainers.core.waiting_utils import wait_container_is_ready
4 | from testcontainers.general import DockerContainer
5 |
6 |
7 | class DockerRegistry(DockerContainer):
8 | _default_exposed_port = 5000
9 |
10 | def __init__(self):
11 | super().__init__("registry:2")
12 | self.with_exposed_ports(self._default_exposed_port)
13 |
14 | @wait_container_is_ready(requests.exceptions.ConnectionError)
15 | def remote_name(self, for_vm: str):
16 | exposed_port = self.get_exposed_port(self._default_exposed_port)
17 |
18 | requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
19 |
20 | return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
21 |
--------------------------------------------------------------------------------
/integration-tests/requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | testcontainers
3 | requests
4 | bitmath
5 | pytest-dependency
6 |
--------------------------------------------------------------------------------
/integration-tests/tart.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | import os
3 | import subprocess
4 |
5 |
6 | class Tart:
7 | def __init__(self):
8 | self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
9 |
10 | # Link to the users IPSW cache to make things faster
11 | src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
12 | dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
13 | os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
14 | os.symlink(src, dst)
15 |
16 | def __enter__(self):
17 | return self
18 |
19 | def __exit__(self, exc_type, exc_val, exc_tb):
20 | self.tmp_dir.cleanup()
21 |
22 | def home(self) -> str:
23 | return self.tmp_dir.name
24 |
25 | def run(self, args):
26 | env = os.environ.copy()
27 | env.update({"TART_HOME": self.tmp_dir.name})
28 |
29 | completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
30 |
31 | completed_process.check_returncode()
32 |
33 | return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
34 |
--------------------------------------------------------------------------------
/integration-tests/test_clone.py:
--------------------------------------------------------------------------------
1 | def test_clone(tart):
2 | # Create a Linux VM (because we can create it really fast)
3 | tart.run(["create", "--linux", "debian"])
4 |
5 | # Clone the VM
6 | tart.run(["clone", "debian", "ubuntu"])
7 |
8 | # Ensure that we have now 2 VMs
9 | stdout, _, = tart.run(["list", "--quiet"])
10 | assert stdout == "debian\nubuntu\n"
11 |
--------------------------------------------------------------------------------
/integration-tests/test_create.py:
--------------------------------------------------------------------------------
1 | def test_create_macos(tart):
2 | # Create a macOS VM
3 | tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
4 |
5 | # Ensure that the VM was created
6 | stdout, _ = tart.run(["list", "--quiet"])
7 | assert stdout == "macos-vm\n"
8 |
9 |
10 | def test_create_linux(tart):
11 | # Create a Linux VM
12 | tart.run(["create", "--linux", "linux-vm"])
13 |
14 | # Ensure that the VM was created
15 | stdout, _ = tart.run(["list", "--quiet"])
16 | assert stdout == "linux-vm\n"
17 |
--------------------------------------------------------------------------------
/integration-tests/test_delete.py:
--------------------------------------------------------------------------------
1 | def test_delete(tart):
2 | # Create a Linux VM (because we can create it really fast)
3 | tart.run(["create", "--linux", "debian"])
4 |
5 | # Ensure that the VM exists
6 | stdout, _, = tart.run(["list", "--quiet"])
7 | assert stdout == "debian\n"
8 |
9 | # Delete the VM
10 | tart.run(["delete", "debian"])
11 |
12 | # Ensure that the VM was removed
13 | stdout, _, = tart.run(["list", "--quiet"])
14 | assert stdout == ""
15 |
--------------------------------------------------------------------------------
/integration-tests/test_oci.py:
--------------------------------------------------------------------------------
1 | import os
2 | import tempfile
3 | import timeit
4 | import uuid
5 |
6 | import bitmath
7 | import pytest
8 |
9 | amount_to_transfer = bitmath.GB(1)
10 | minimal_speed_per_second = bitmath.Mb(100)
11 |
12 |
13 | class TestOCI:
14 | @pytest.mark.dependency()
15 | def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
16 | start = timeit.default_timer()
17 | tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
18 | stop = timeit.default_timer()
19 |
20 | actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
21 | assert actual_speed_per_second > minimal_speed_per_second
22 |
23 | @pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
24 | def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
25 | start = timeit.default_timer()
26 | tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
27 | stop = timeit.default_timer()
28 |
29 | actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
30 | assert actual_speed_per_second > minimal_speed_per_second
31 |
32 | @staticmethod
33 | def _calculate_speed_per_second(amount_transferred, time_taken):
34 | return (amount_transferred / time_taken).best_prefix(bitmath.SI)
35 |
36 |
37 | @pytest.fixture(scope="class")
38 | def vm_with_random_disk(tart):
39 | vm_name = str(uuid.uuid4())
40 |
41 | # Create a VM (Linux for speed's sake)
42 | tart.run(["create", "--linux", vm_name])
43 |
44 | # Populate VM's disk with "amount_to_transfer" of random bytes
45 | # to effectively disable Tart's OCI blob compression
46 | disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
47 |
48 | with tempfile.NamedTemporaryFile(delete=False) as tf:
49 | tf.write(os.urandom(amount_to_transfer.bytes))
50 | tf.close()
51 | os.rename(tf.name, disk_path)
52 |
53 | yield vm_name
54 |
55 | tart.run(["delete", vm_name])
56 |
--------------------------------------------------------------------------------
/integration-tests/test_rename.py:
--------------------------------------------------------------------------------
1 | def test_rename(tart):
2 | # Create a Linux VM (because we can create it really fast)
3 | tart.run(["create", "--linux", "debian"])
4 |
5 | # Rename that VM
6 | tart.run(["rename", "debian", "ubuntu"])
7 |
8 | # Ensure that the VM is now named "ubuntu"
9 | stdout, _, = tart.run(["list", "--quiet"])
10 | assert stdout == "ubuntu\n"
11 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | repo_url: https://github.com/cirruslabs/tart/
2 | site_url: https://tart.run/
3 | edit_uri: blob/main/docs/
4 |
5 | site_name: Tart
6 | site_author: Cirrus Labs
7 | copyright: © Cirrus Labs 2017-present
8 | site_description: >
9 | Tart is a virtualization toolset to build, run and manage macOS and Linux virtual machines (VMs) on Apple Silicon.
10 | Built by CI engineers for your automation needs.
11 |
12 | remote_branch: main
13 |
14 | theme:
15 | name: 'material'
16 | custom_dir: 'docs/theme'
17 | favicon: 'assets/images/favicon.ico'
18 | logo: 'assets/images/TartLogo.png'
19 | icon:
20 | repo: fontawesome/brands/github
21 | language: en
22 | palette:
23 | - scheme: default
24 | primary: orange
25 | accent: orange
26 | font:
27 | text: Roboto
28 | code: Roboto Mono
29 | features:
30 | - announce.dismiss
31 | - content.tabs.link
32 | - content.code.copy
33 | - navigation.tabs
34 | - navigation.tabs.sticky
35 | - navigation.top
36 | - search.suggest
37 | - toc.follow
38 |
39 | extra_css:
40 | - 'stylesheets/extra.css'
41 | - 'stylesheets/landing.css'
42 |
43 | plugins:
44 | - blog
45 | - privacy
46 | - rss:
47 | match_path: blog/posts/.*
48 | date_from_meta:
49 | as_creation: date
50 | - social
51 | - search
52 | - minify
53 |
54 | markdown_extensions:
55 | - markdown.extensions.admonition
56 | - markdown.extensions.codehilite:
57 | guess_lang: false
58 | - markdown.extensions.def_list
59 | - markdown.extensions.footnotes
60 | - markdown.extensions.meta
61 | - markdown.extensions.toc:
62 | permalink: true
63 | - pymdownx.arithmatex
64 | - pymdownx.betterem:
65 | smart_enable: all
66 | - pymdownx.caret
67 | - pymdownx.critic
68 | - pymdownx.details
69 | - pymdownx.emoji:
70 | emoji_generator: !!python/name:pymdownx.emoji.to_svg
71 | - pymdownx.highlight:
72 | anchor_linenums: true
73 | - pymdownx.inlinehilite
74 | - pymdownx.snippets
75 | - pymdownx.superfences
76 | - pymdownx.keys
77 | - pymdownx.magiclink
78 | - pymdownx.mark
79 | - pymdownx.smartsymbols
80 | - pymdownx.tabbed:
81 | alternate_style: true
82 | - pymdownx.tasklist:
83 | custom_checkbox: true
84 | - pymdownx.tilde
85 |
86 | nav:
87 | - "Home": index.md
88 | - "Quick Start": quick-start.md
89 | - "Integrations":
90 | - "GitHub Actions": integrations/github-actions.md
91 | - "GitLab Runner": integrations/gitlab-runner.md
92 | - "Self-hosted CI": integrations/cirrus-cli.md
93 | - "Managing VMs": integrations/vm-management.md
94 | - "Support & Licensing": licensing.md
95 | - "Orchestration": https://github.com/cirruslabs/orchard
96 | - "FAQ": faq.md
97 | - "Legal":
98 | - 'Terms of Service': legal/terms.md
99 | - 'Privacy': legal/privacy.md
100 | - Blog:
101 | - blog/index.md
102 |
103 | extra:
104 | analytics:
105 | provider: google
106 | property: G-HXBEB9D47X
107 | consent:
108 | title: Cookie consent
109 | description: >-
110 | We use cookies to recognize your repeated visits and preferences, as well
111 | as to measure the effectiveness of our documentation and whether users
112 | find what they're searching for. With your consent, you're helping us to
113 | make our documentation better.
114 | social:
115 | - icon: fontawesome/brands/twitter
116 | link: 'https://twitter.com/cirrus_labs'
117 |
--------------------------------------------------------------------------------
/scripts/run-signed.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # helper script to build and run a signed tart binary
4 | # usage: ./scripts/run-signed.sh run ventura-base
5 |
6 | set -e
7 |
8 | swift build --product tart
9 | codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
10 |
11 | .build/debug/tart "$@"
12 |
--------------------------------------------------------------------------------