├── .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 | 3 | cirrus-logo 4 | 5 | 6 | 14 | 15 | 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 | ![](/assets/images/TartCirrusCLI.gif) 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 | ![](/assets/images/TartGHARunners.png) 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 | --------------------------------------------------------------------------------