Compare commits

..
31 Commits
Author SHA1 Message Date
Fedor Korotkov f9001304c8 Fixed packaging 2022-09-14 10:06:22 -04:00
Nikolay Edigaryev 4e20ea8f72 tart run: introduce --net-bridged (#245)
* tart run: introduce --net-bridged

* tart.entitlements: add com.apple.vm.networking
2022-09-14 17:53:04 +04:00
Nikolay EdigaryevandPete Goldsmith 678ce0a55a Introduce "tart rename" command to rename VMs (#246)
* Introduce "tart rename" command to rename VMs

* Remove unused SystemConfiguration import

* Update Sources/tart/Commands/Rename.swift

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>

Co-authored-by: Pete Goldsmith <peter.n.goldsmith@gmail.com>
2022-09-14 17:44:26 +04:00
Fedor Korotkov 8273ae66e1 Update Developer Certificates (#242)
* Update Developer Certificates

Now the signature will state `Cirrus Labs, Inc.` and not `Fedor Korotkov`.

* Updated identity
2022-09-13 09:36:35 -04:00
Fedor Korotkov 4a9316a377 [skip ci] Add sponsorship option (#241) 2022-09-13 16:30:26 +04:00
Fedor Korotkov 6321d547cf Improve available capacity checking (#240)
* Improve available capacity checking

* Fixed expression
2022-09-12 21:55:25 +04:00
Nikolay Edigaryev f241e21614 Self-hosted temporary directory (#238) 2022-09-12 20:56:52 +04:00
Nikolay Edigaryev 0eca923604 Document NAT subnet change procedure (#236) 2022-09-08 16:26:40 +04:00
Pete Goldsmith 87f29cc11f Clarify requirements for dir argument (#231) 2022-09-07 09:02:46 -04:00
Pete Goldsmith 90d1393137 Handle tilde in path for directory share (#233)
* Expand Tilde in path

* Expand tilde in disk paths
2022-09-07 08:15:29 -04:00
Nikolay Edigaryev 625d431d10 Revert "Improve RemoteName parser (#225)" (#230)
This reverts commit ae7018c31f.
2022-09-06 22:13:30 +04:00
Nikolay Edigaryev 4648e1aea1 tart clone: clone VM and generate MAC under a file lock (#215)
* tart clone: clone VM and generate MAC under a file lock

* Lock concurrent "tart pull"'s for the same host

* Config: ensure Tart's home and cache directories always exist
2022-09-06 21:33:51 +04:00
Fedor Korotkov 4b62b73015 Document Adopters (#229)
* Document early adopters

Related to #208

* Use HTML
2022-09-06 21:25:22 +04:00
Nikolay Edigaryev ae7018c31f Improve RemoteName parser (#225)
* Improve RemoteName parser

* Remove Parsing import

* Permit namespace components to contain separators, but no more than one

* Add testNoPathTraversal
2022-09-06 17:28:49 +04:00
Pete Goldsmith e54c89da0c Clarify if guest or host is unsupported (#228) 2022-09-06 09:23:41 -04:00
Nikolay Edigaryev 9a0ec3e6b0 Fix cache reclaimed bytes calculation (#223) 2022-09-01 16:12:48 -04:00
Fedor Korotkov 400f85a493 Check Tart Home for auto-pruning (#220)
Otherwise it's weird we check $TMP but prune $TART_HOME
2022-09-01 15:44:19 -04:00
Fedor Korotkov 0105280b5d Support plaintext auths from Docker config (#219) 2022-09-01 23:10:52 +04:00
Fedor Korotkov c063c5bdc2 Include version in installer (#218) 2022-09-01 19:42:50 +04:00
Fedor Korotkov 8a2b0151e0 Create Apple Installer in dist folder (#217)
Seems goreleaser reset git state before submitting and therefore removes Tart.pkg
2022-09-01 18:04:43 +04:00
Fedor Korotkov 2eea51d6ec Create Apple Installer (#216)
The installer will install tart binary to `/usr/local/bin/tart`

Fixes #153
2022-08-31 18:36:28 -04:00
Fedor Korotkov 1890909d28 Sign release binaries (#207)
* Sign release binaries

Should fix #184

* Use VMs for building

* Test release

* Fixed password

* Revert testing
2022-08-30 16:26:06 -04:00
Nikolay Edigaryev 0cad2e454c Directory sharing support (#211) 2022-08-30 10:26:16 +04:00
Nikolay Edigaryev 17dcf942c1 Terminate VM on GUI window close (#210) 2022-08-30 02:41:58 +04:00
Nikolay Edigaryev 6296df7c0c Credential helpers: "credHelpers" map is optional in Docker's config (#209) 2022-08-29 16:38:51 -04:00
Fedor Korotkov c54b140750 Support Docker Helpers (#205)
Fixes #167
2022-08-29 20:26:53 +04:00
Fedor Korotkov 048a5506df Support trackpad on macOS and clipboard sharing on Linux (#202)
Fixes #66
Relates to #14 since fixes on Linux
2022-08-27 20:05:45 +04:00
Nikolay Edigaryev 553b36349b README.md: clarify "Pulling a Remote Image" section (#196) 2022-08-25 15:43:30 +04:00
Fedor Korotkov d0bf286aa1 Document Linux VM image creation (#193) 2022-08-24 23:21:50 +04:00
Nikolay Edigaryev 195a4b3a72 Make UnsupportedOSError more user-friendly (#192)
* Make UnsupportedOSError more user-friendly

* tart prune: allow only specifying a --gc flag
2022-08-24 15:03:54 -04:00
Nikolay Edigaryev 59393df2e1 Linux support (#190)
* Linux support

* Support macOS 12

* Improve readability a bit

* Remove useless variable
2022-08-23 22:38:50 +04:00
40 changed files with 908 additions and 163 deletions
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
export VERSION="${CIRRUS_TAG:-0}"
mkdir -p .ci/pkg/
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
+15 -1
View File
@@ -22,10 +22,24 @@ task:
macos_instance: macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env: env:
MACOS_CERTIFICATE: ENCRYPTED[552b9d275d1c2bdbc1bff778b104a8f9a53cbd0d59344d4b7f6d0ca3c811a5cefb97bef9ba0ef31c219cb07bdacdd2c2]
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!] GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!] GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
install_script: brew install go goreleaser/tap/goreleaser-pro setup_script:
- cd $HOME
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
- security create-keychain -p password101 build.keychain
- security default-keychain -s build.keychain
- security unlock-keychain -p password101 build.keychain
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
install_script:
- brew install go goreleaser/tap/goreleaser-pro
- brew install mitchellh/gon/gon
info_script: info_script:
- security find-identity -v
- xcodebuild -version - xcodebuild -version
- swift -version - swift -version
release_script: goreleaser release_script: goreleaser
+1
View File
@@ -0,0 +1 @@
github: [cirruslabs]
+7 -2
View File
@@ -7,13 +7,16 @@ builds:
goarch: goarch:
- arm64 - arm64
prebuilt: prebuilt:
path: .build/{{ .Arch }}-apple-macosx/debug/tart path: .build/arm64-apple-macosx/debug/tart
hooks:
post:
- gon gon.hcl
- .ci/create-pkg.sh
before: before:
hooks: hooks:
- .ci/set-version.sh - .ci/set-version.sh
- swift build -c debug --product tart - swift build -c debug --product tart
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/debug/tart
archives: archives:
- id: binary - id: binary
@@ -24,6 +27,8 @@ archives:
release: release:
prerelease: auto prerelease: auto
extra_files:
- glob: ./dist/Tart-{{ .Tag }}.pkg
brews: brews:
- name: tart - name: tart
+74 -4
View File
@@ -1,6 +1,6 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png) ![Tart – open source virtualization for your automation needs](Resources/TartSocial.png)
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon. *Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart: Built by CI engineers for your automation needs. Here are some highlights of Tart:
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339). * Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
@@ -8,6 +8,22 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Use Tart Packer Plugin to automate VM creation. * Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration. * Built-in CI integration.
*Tart* is already adopted by several automation services:
<p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/CirrusCI.png" height="65"/>
</a>
<a href="https://codemagic.io/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Codemagic.png" height="65"/>
</a>
<a href="https://testingbot.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/TestingBot.png" height="65"/>
</a>
</p>
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image): Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
```shell ```shell
@@ -84,6 +100,11 @@ For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/bina
### Creating from scratch ### Creating from scratch
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
#### Creating a macOS VM image from scratch
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
use `latest` instead of a path to `*.ipsw` to download the latest available version: use `latest` instead of a path to `*.ipsw` to download the latest available version:
@@ -100,6 +121,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
4. Disable Screen Saver. 4. Disable Screen Saver.
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password. 5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
#### Creating a Linux VM image from scratch
```bash
# Create a bare VM
tart create --linux ubuntu
# Install Ubuntu
tart run --disk focal-desktop-arm64.iso ubuntu
# Run VM
tart run ubuntu
```
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
```shell
sudo apt update
sudo apt install -y openssh-server
sudo ufw allow ssh
```
### Configuring a VM ### Configuring a VM
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command. By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
@@ -165,10 +207,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
#### Pulling a Remote Image #### Pulling a Remote Image
You can either pull an image:
```shell ```shell
tart pull acme.io/remoteorg/name:latest my-local-vm-name tart pull acme.io/remoteorg/name:latest
``` ```
...or instantiate a VM from a remote image:
```shell
tart clone acme.io/remoteorg/name:latest my-local-vm-name
```
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
## FAQ ## FAQ
<details> <details>
@@ -186,8 +238,8 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
<details> <details>
<summary>Why Tart is free and open sourced?</summary> <summary>Why Tart is free and open sourced?</summary>
Tart is a relatively small project, and it didn't feel right to try to monetize it. Apple did all the heavy lifting with their `Virtualization.Framework` and it just felt right to develop Tart in the open.
Apple did all the heavy lifting with their `Virtualization.Framework`. Please consider [becoming a sponsor](https://github.com/sponsors/cirruslabs) if you find Tart saving a substantial amount of money on licensing and engineering hours for your company.
</details> </details>
<details> <details>
@@ -216,3 +268,21 @@ tart pull acme.io/remoteorg/name:latest my-local-vm-name
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework` Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
doesn't support nested virtualization. doesn't support nested virtualization.
</details> </details>
<details>
<summary>Changing the default NAT subnet</summary>
To change the default network to `192.168.77.1`:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Address -string 192.168.77.1
```
Note that even through a network would normally be specified as `192.168.77.0`, the [vmnet framework](https://developer.apple.com/documentation/vmnet) seems to treat this as a starting address too and refuses to pick up such network-like values.
The default subnet mask `255.255.255.0` should suffice for most use-cases, however, you can also change it to `255.255.0.0`, for example:
```
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.vmnet.plist Shared_Net_Mask -string 255.255.0.0
```
</details>
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
size 6787
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
size 14588
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
size 18393
+3 -1
View File
@@ -4,5 +4,7 @@
<dict> <dict>
<key>com.apple.security.virtualization</key> <key>com.apple.security.virtualization</key>
<true/> <true/>
<key>com.apple.vm.networking</key>
<true/>
</dict> </dict>
</plist> </plist>
+11 -1
View File
@@ -32,12 +32,22 @@ struct Clone: AsyncParsableCommand {
} }
let sourceVM = try VMStorageHelper.open(sourceName) let sourceVM = try VMStorageHelper.open(sourceName)
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
let lock = try FileLock(lockURL: Config().tartHomeDir)
try lock.lock()
let generateMAC = try localStorage.hasVMsWithMACAddress(macAddress: sourceVM.macAddress())
try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC) try sourceVM.clone(to: tmpVMDir, generateMAC: generateMAC)
try localStorage.move(newName, from: tmpVMDir) try localStorage.move(newName, from: tmpVMDir)
try lock.unlock()
}, onCancel: { }, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL) try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
}) })
+25 -7
View File
@@ -9,26 +9,44 @@ struct Create: AsyncParsableCommand {
@Argument(help: "VM name") @Argument(help: "VM name")
var name: String var name: String
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path")) @Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
var fromIPSW: String? var fromIPSW: String?
@Flag(help: "create a Linux VM")
var linux: Bool = false
@Option(help: ArgumentHelp("Disk size in Gb")) @Option(help: ArgumentHelp("Disk size in Gb"))
var diskSize: UInt16 = 50 var diskSize: UInt16 = 50
func validate() throws { func validate() throws {
if fromIPSW == nil { if fromIPSW == nil && !linux {
throw ValidationError("Please specify a --from-ipsw option!") throw ValidationError("Please specify either a --from-ipsw or --linux option!")
} }
} }
func run() async throws { func run() async throws {
do { do {
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
try await withTaskCancellationHandler(operation: { try await withTaskCancellationHandler(operation: {
if fromIPSW! == "latest" { if let fromIPSW = fromIPSW {
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize) if fromIPSW == "latest" {
} else { _ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize) } else {
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
}
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
} }
try VMStorageLocal().move(name, from: tmpVMDir) try VMStorageLocal().move(name, from: tmpVMDir)
+1 -1
View File
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
do { do {
let registry = try Registry(host: host, namespace: "", insecure: insecure, let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProvider: credentialsProvider) credentialsProviders: [credentialsProvider])
try await registry.ping() try await registry.ping()
} catch { } catch {
print("invalid credentials: \(error)") print("invalid credentials: \(error)")
+3 -3
View File
@@ -20,8 +20,8 @@ struct Prune: AsyncParsableCommand {
var gc: Bool = false var gc: Bool = false
func validate() throws { func validate() throws {
if olderThan == nil && cacheBudget == nil { if olderThan == nil && cacheBudget == nil && !gc {
throw ValidationError("at least one criteria must be specified") throw ValidationError("at least one pruning criteria must be specified")
} }
} }
@@ -101,7 +101,7 @@ struct Prune: AsyncParsableCommand {
break break
} }
cacheReclaimedBytes -= try prunable.sizeBytes() cacheReclaimedBytes += try prunable.sizeBytes()
try prunable.delete() try prunable.delete()
} }
} }
+40
View File
@@ -0,0 +1,40 @@
import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name")
var name: String
@Argument(help: "new VM name")
var newName: String
func validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
let localStorage = VMStorageLocal()
if !localStorage.exists(name) {
throw ValidationError("failed to rename a non-existent VM: \(name)")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
}
try localStorage.rename(name, newName)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+99 -5
View File
@@ -37,17 +37,38 @@ struct Run: AsyncParsableCommand {
@Flag var withSoftnet: Bool = false @Flag var withSoftnet: Bool = false
@Option(help: ArgumentHelp(""" @Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"disk.bin:ro\") Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """ """, discussion: """
Learn how to create a disk image using Disk Utility here: Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""")) """, valueName: "path[:ro]"))
var disk: [String] = [] var disk: [String] = []
@Option(help: ArgumentHelp("""
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
""", discussion: """
Requires host to be macOS 13.0 (Ventura) or newer.
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point".
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
""", valueName: "name:path[:ro]"))
var dir: [String] = []
@Option(help: ArgumentHelp("""
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
func validate() throws { func validate() throws {
if vnc && vncExperimental { if vnc && vncExperimental {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive") throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
} }
if withSoftnet && netBridged != nil {
throw ValidationError("--with-softnet and --net-bridged are mutually exclusive")
}
} }
@MainActor @MainActor
@@ -55,8 +76,9 @@ struct Run: AsyncParsableCommand {
let vmDir = try VMStorageLocal().open(name) let vmDir = try VMStorageLocal().open(name)
vm = try VM( vm = try VM(
vmDir: vmDir, vmDir: vmDir,
withSoftnet: withSoftnet, network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments() additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
) )
let vncImpl: VNC? = try { let vncImpl: VNC? = try {
@@ -114,11 +136,53 @@ struct Run: AsyncParsableCommand {
} }
} }
func userSpecifiedNetwork(vmDir: VMDirectory) throws -> Network? {
if withSoftnet {
let config = try VMConfig.init(fromURL: vmDir.configURL)
return try Softnet(vmMACAddress: config.macAddress.string)
}
if let netBridged = netBridged {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
}
if matchingInterfaces.isEmpty {
let available = bridgeInterfaces().joined(separator: ", ")
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(available)")
}
if matchingInterfaces.count > 1 {
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
+ "consider refining the search criteria")
}
return NetworkBridged(interface: matchingInterfaces.first!)
}
return nil
}
func bridgeInterfaces() -> [String] {
VZBridgedNetworkInterface.networkInterfaces.map { interface in
var bridgeDescription = interface.identifier
if let localizedDisplayName = interface.localizedDisplayName {
bridgeDescription += " (or \"\(localizedDisplayName)\")"
}
return bridgeDescription
}
}
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] { func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
var result: [VZDiskImageStorageDeviceAttachment] = [] var result: [VZDiskImageStorageDeviceAttachment] = []
let readOnlySuffix = ":ro" let readOnlySuffix = ":ro"
let expandedDiskPaths = disk.map { NSString(string:$0).expandingTildeInPath }
for rawDisk in disk { for rawDisk in expandedDiskPaths {
if rawDisk.hasSuffix(readOnlySuffix) { if rawDisk.hasSuffix(readOnlySuffix) {
result.append(try VZDiskImageStorageDeviceAttachment( result.append(try VZDiskImageStorageDeviceAttachment(
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))), url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
@@ -135,6 +199,34 @@ struct Run: AsyncParsableCommand {
return result return result
} }
func directoryShares() throws -> [DirectoryShare] {
var result: [DirectoryShare] = []
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
if splits.count < 2 {
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
}
var readOnly: Bool = false
if splits.count == 3 {
if splits[2] == "ro" {
readOnly = true
} else {
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
}
}
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
}
return result
}
private func runUI() { private func runUI() {
let nsApp = NSApplication.shared let nsApp = NSApplication.shared
nsApp.setActivationPolicy(.regular) nsApp.setActivationPolicy(.regular)
@@ -148,6 +240,8 @@ struct Run: AsyncParsableCommand {
Group { Group {
VMView(vm: vm!).onAppear { VMView(vm: vm!).onAppear {
NSWindow.allowsAutomaticWindowTabbing = false NSWindow.allowsAutomaticWindowTabbing = false
}.onDisappear {
NSApplication.shared.terminate(self)
} }
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height)) }.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
}.commands { }.commands {
+21 -2
View File
@@ -3,8 +3,9 @@ import Foundation
struct Config { struct Config {
let tartHomeDir: URL let tartHomeDir: URL
let tartCacheDir: URL let tartCacheDir: URL
let tartTmpDir: URL
init() { init() throws {
var tartHomeDir: URL var tartHomeDir: URL
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] { if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
@@ -14,9 +15,27 @@ struct Config {
.homeDirectoryForCurrentUser .homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true) .appendingPathComponent(".tart", isDirectory: true)
} }
self.tartHomeDir = tartHomeDir self.tartHomeDir = tartHomeDir
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true) tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
try FileManager.default.createDirectory(at: tartCacheDir, withIntermediateDirectories: true)
tartTmpDir = tartHomeDir.appendingPathComponent("tmp", isDirectory: true)
try FileManager.default.createDirectory(at: tartTmpDir, withIntermediateDirectories: true)
}
func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry)
if try !lock.trylock() {
continue
}
try FileManager.default.removeItem(at: entry)
try lock.unlock()
}
} }
static func jsonEncoder() -> JSONEncoder { static func jsonEncoder() -> JSONEncoder {
@@ -0,0 +1,88 @@
import Foundation
class DockerConfigCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
return nil
}
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = config.credHelpers?[host] {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
return nil
}
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
guard let executableURL = resolveBinaryPath(binaryName) else {
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
}
let process = Process.init()
process.executableURL = executableURL
process.arguments = ["get"]
let outPipe = Pipe()
let inPipe = Pipe()
process.standardOutput = outPipe
process.standardError = outPipe
process.standardInput = inPipe
process.launch()
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
}
let getOutput = try JSONDecoder().decode(
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
)
return (getOutput.Username, getOutput.Secret)
}
func store(host: String, user: String, password: String) throws {
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
}
}
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
}
struct DockerAuthConfig: Codable {
var auth: String? = nil
func decodeCredentials() -> (String, String)? {
// auth is a base64("username:password")
guard let authBase64 = auth else {
return nil
}
guard let data = Data(base64Encoded: authBase64) else {
return nil
}
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
return nil
}
if components.count != 2 {
return nil
}
return (components[0], components[1])
}
}
struct DockerGetOutput: Codable {
var Username: String
var Secret: String
}
+48
View File
@@ -0,0 +1,48 @@
import Foundation
import System
enum FileLockError: Error, Equatable {
case Failed(_ message: String)
case AlreadyLocked
}
class FileLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, 0)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
try flockWrapper(LOCK_EX | LOCK_NB)
}
func lock() throws {
_ = try flockWrapper(LOCK_EX)
}
func unlock() throws {
_ = try flockWrapper(LOCK_UN)
}
func flockWrapper(_ operation: Int32) throws -> Bool {
let ret = flock(fd, operation)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
if (operation & LOCK_NB) != 0 && details == .wouldBlock {
return false
}
throw FileLockError.Failed("failed to lock \(url): \(details)")
}
return true
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ class IPSWCache: PrunableStorage {
let baseURL: URL let baseURL: URL
init() throws { init() throws {
baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true) baseURL = try Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
} }
+7
View File
@@ -0,0 +1,7 @@
import Virtualization
protocol Network {
func attachment() -> VZNetworkDeviceAttachment
func run() throws
func stop() throws
}
+22
View File
@@ -0,0 +1,22 @@
import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
}
func run() throws {
// no-op, only used for Softnet
}
func stop() throws {
// no-op, only used for Softnet
}
}
+16
View File
@@ -0,0 +1,16 @@
import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
}
func run() throws {
// no-op, only used for Softnet
}
func stop() throws {
// no-op, only used for Softnet
}
}
@@ -1,10 +1,11 @@
import Foundation import Foundation
import Virtualization
enum SoftnetError: Error { enum SoftnetError: Error {
case InitializationFailed(why: String) case InitializationFailed(why: String)
} }
class Softnet { class Softnet: Network {
private let process = Process() private let process = Process()
let vmFD: Int32 let vmFD: Int32
@@ -12,7 +13,7 @@ class Softnet {
init(vmMACAddress: String) throws { init(vmMACAddress: String) throws {
let binaryName = "softnet" let binaryName = "softnet"
guard let executableURL = Self.resolveBinaryPath(binaryName) else { guard let executableURL = resolveBinaryPath(binaryName) else {
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH") throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
} }
@@ -43,23 +44,6 @@ class Softnet {
process.waitUntilExit() process.waitUntilExit()
} }
private static func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws { private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
var option_value = sizeBytes var option_value = sizeBytes
let option_len = socklen_t(MemoryLayout<Int>.size) let option_len = socklen_t(MemoryLayout<Int>.size)
@@ -74,4 +58,9 @@ class Softnet {
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)") throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
} }
} }
func attachment() -> VZNetworkDeviceAttachment {
let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
}
} }
+2 -2
View File
@@ -46,8 +46,8 @@ struct OCIManifest: Codable, Equatable {
} }
struct OCIConfig: Codable { struct OCIConfig: Codable {
var architecture: String = "arm64" var architecture: Architecture = .arm64
var os: String = "darwin" var os: OS = .darwin
func toJSON() throws -> Data { func toJSON() throws -> Data {
try Config.jsonEncoder().encode(self) try Config.jsonEncoder().encode(self)
+16 -7
View File
@@ -88,29 +88,29 @@ class Registry {
let baseURL: URL let baseURL: URL
let namespace: String let namespace: String
let credentialsProvider: CredentialsProvider let credentialsProviders: [CredentialsProvider]
var currentAuthToken: Authentication? = nil var currentAuthToken: Authentication? = nil
init(urlComponents: URLComponents, init(urlComponents: URLComponents,
namespace: String, namespace: String,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
baseURL = urlComponents.url! baseURL = urlComponents.url!
self.namespace = namespace self.namespace = namespace
self.credentialsProvider = credentialsProvider self.credentialsProviders = credentialsProviders
} }
convenience init( convenience init(
host: String, host: String,
namespace: String, namespace: String,
insecure: Bool = false, insecure: Bool = false,
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider() credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws { ) throws {
let proto = insecure ? "http" : "https" let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")! let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider) try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
} }
func ping() async throws { func ping() async throws {
@@ -303,7 +303,7 @@ class Registry {
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw) let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" { if wwwAuthenticate.scheme == "Basic" {
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) { if let (user, password) = try lookupCredentials(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password) currentAuthToken = BasicAuthentication(user: user, password: password)
} }
@@ -340,7 +340,7 @@ class Registry {
var headers: Dictionary<String, String> = Dictionary() var headers: Dictionary<String, String> = Dictionary()
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) { if let (user, password) = try lookupCredentials(host: baseURL.host!) {
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString() let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
headers["Authorization"] = "Basic \(encodedCredentials!)" headers["Authorization"] = "Basic \(encodedCredentials!)"
} }
@@ -356,6 +356,15 @@ class Registry {
currentAuthToken = try TokenResponse.parse(fromData: bodyData) currentAuthToken = try TokenResponse.parse(fromData: bodyData)
} }
private func lookupCredentials(host: String) throws -> (String, String)? {
for provider in credentialsProviders {
if let (user, password) = try provider.retrieve(host: host) {
return (user, password)
}
}
return nil
}
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse { private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
var request = request var request = request
+14
View File
@@ -0,0 +1,14 @@
import Foundation
enum Architecture: String, Codable {
case arm64
case amd64
}
func CurrentArchitecture() -> Architecture {
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
}
+97
View File
@@ -0,0 +1,97 @@
import Virtualization
struct Darwin: Platform {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid
self.hardwareModel = hardwareModel
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let encodedECID = try container.decode(String.self, forKey: .ecid)
guard let data = Data.init(base64Encoded: encodedECID) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize Data using the provided value")
}
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
}
func os() -> OS {
.darwin
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
VZMacOSBootLoader()
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
let result = VZMacPlatformConfiguration()
result.machineIdentifier = ecid
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
result.hardwareModel = hardwareModel
return result
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZMacGraphicsDeviceConfiguration()
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
result.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
return result
}
result.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// A reasonable guess according to Apple's documentation[1]
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported starting with macOS Ventura
// macOS Monterey will continue using a USB device == .darwin
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
} else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
func os() -> OS {
.linux
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL)
return result
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
+6
View File
@@ -0,0 +1,6 @@
import Virtualization
enum OS: String, Codable {
case darwin
case linux
}
+9
View File
@@ -0,0 +1,9 @@
import Virtualization
protocol Platform: Codable {
func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func pointingDevices() -> [VZPointingDeviceConfiguration]
}
+4
View File
@@ -17,6 +17,7 @@ struct Root: AsyncParsableCommand {
Pull.self, Pull.self,
Push.self, Push.self,
Prune.self, Prune.self,
Rename.self,
Delete.self, Delete.self,
]) ])
@@ -36,6 +37,9 @@ struct Root: AsyncParsableCommand {
do { do {
var command = try parseAsRoot() var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
try Config().gc()
if var asyncCommand = command as? AsyncParsableCommand { if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run() try await asyncCommand.run()
} else { } else {
+17
View File
@@ -5,3 +5,20 @@ extension Collection {
indices.contains(index) ? self[index] : nil indices.contains(index) ? self[index] : nil
} }
} }
func resolveBinaryPath(_ name: String) -> URL? {
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
return nil
}
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
}
}
return nil
}
+77 -64
View File
@@ -10,6 +10,17 @@ struct NoMainScreenFoundError: Error {
struct DownloadFailed: Error { struct DownloadFailed: Error {
} }
struct UnsupportedOSError: Error, CustomStringConvertible {
let description: String
init(_ what: String, _ plural: String) {
description = "error: \(what) \(plural) only supported on hosts running macOS 13.0 (Ventura) or newer"
}
}
struct UnsupportedArchitectureError: Error {
}
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject { class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Virtualization.Framework's virtual machine // Virtualization.Framework's virtual machine
@Published var virtualMachine: VZVirtualMachine @Published var virtualMachine: VZVirtualMachine
@@ -23,24 +34,26 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config // VM's config
var config: VMConfig var config: VMConfig
var softnet: Softnet? = nil var network: Network
init(vmDir: VMDirectory, init(vmDir: VMDirectory,
withSoftnet: Bool = false, network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
) throws { ) throws {
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
name = vmDir.name name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL) config = try VMConfig.init(fromURL: vmDir.configURL)
// Initialize the virtual machine and its configuration if config.arch != CurrentArchitecture() {
if withSoftnet { throw UnsupportedArchitectureError()
softnet = try Softnet(vmMACAddress: config.macAddress.string)
} }
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, // Initialize the virtual machine and its configuration
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments) self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -98,7 +111,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmDir: VMDirectory, vmDir: VMDirectory,
ipswURL: URL?, ipswURL: URL?,
diskSizeGB: UInt16, diskSizeGB: UInt16,
withSoftnet: Bool = false, network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
) async throws { ) async throws {
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW(); let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
@@ -116,7 +129,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
// Create NVRAM // Create NVRAM
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel) _ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
// Create disk // Create disk
try vmDir.resizeDisk(diskSizeGB) try vmDir.resizeDisk(diskSizeGB)
@@ -124,7 +137,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
name = vmDir.name name = vmDir.name
// Create config // Create config
config = VMConfig( config = VMConfig(
hardwareModel: requirements.hardwareModel, platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
cpuCountMin: requirements.minimumSupportedCPUCount, cpuCountMin: requirements.minimumSupportedCPUCount,
memorySizeMin: requirements.minimumSupportedMemorySize memorySizeMin: requirements.minimumSupportedMemorySize
) )
@@ -133,12 +146,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
try config.save(toURL: vmDir.configURL) try config.save(toURL: vmDir.configURL)
// Initialize the virtual machine and its configuration // Initialize the virtual machine and its configuration
if withSoftnet { self.network = network
softnet = try Softnet(vmMACAddress: config.macAddress.string) let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
} vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config, directoryShares: [])
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments)
virtualMachine = VZVirtualMachine(configuration: configuration) virtualMachine = VZVirtualMachine(configuration: configuration)
super.init() super.init()
@@ -159,10 +171,23 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
@available(macOS 13, *)
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
// Create NVRAM
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
// Create disk
try vmDir.resizeDisk(diskSizeGB)
// Create config
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
try config.save(toURL: vmDir.configURL)
return try VM(vmDir: vmDir)
}
func run(_ recovery: Bool) async throws { func run(_ recovery: Bool) async throws {
if let softnet = softnet { try network.run()
try softnet.run()
}
DispatchQueue.main.sync { DispatchQueue.main.sync {
Task { Task {
@@ -192,57 +217,31 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} }
} }
if let softnet = softnet { try network.stop()
try softnet.stop();
}
} }
static func craftConfiguration( static func craftConfiguration(
diskURL: URL, diskURL: URL,
auxStorage: VZMacAuxiliaryStorage, nvramURL: URL,
vmConfig: VMConfig, vmConfig: VMConfig,
softnet: Softnet? = nil, network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
) throws -> VZVirtualMachineConfiguration { ) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration() let configuration = VZVirtualMachineConfiguration()
// Boot loader // Boot loader
configuration.bootLoader = VZMacOSBootLoader() configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
// CPU and memory // CPU and memory
configuration.cpuCount = vmConfig.cpuCount configuration.cpuCount = vmConfig.cpuCount
configuration.memorySize = vmConfig.memorySize configuration.memorySize = vmConfig.memorySize
// Platform // Platform
let platform = VZMacPlatformConfiguration() configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
platform.machineIdentifier = vmConfig.ecid
platform.auxiliaryStorage = auxStorage
platform.hardwareModel = vmConfig.hardwareModel
configuration.platform = platform
// Display // Display
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration() configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
if let hostMainScreen = NSScreen.main {
let vmScreenSize = NSSize(
width: vmConfig.display.width,
height: vmConfig.display.height
)
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
]
} else {
graphicsDeviceConfiguration.displays = [
VZMacGraphicsDisplayConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height,
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
pixelsPerInch: 72
)
]
}
configuration.graphicsDevices = [graphicsDeviceConfiguration]
// Audio // Audio
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration() let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
@@ -255,17 +254,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Keyboard and mouse // Keyboard and mouse
configuration.keyboards = [VZUSBKeyboardConfiguration()] configuration.keyboards = [VZUSBKeyboardConfiguration()]
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()] configuration.pointingDevices = vmConfig.platform.pointingDevices()
// Networking // Networking
let vio = VZVirtioNetworkDeviceConfiguration() let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = network.attachment()
if let softnet = softnet {
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
} else {
vio.attachment = VZNATNetworkDeviceAttachment()
}
vio.macAddress = vmConfig.macAddress vio.macAddress = vmConfig.macAddress
configuration.networkDevices = [vio] configuration.networkDevices = [vio]
@@ -277,6 +270,20 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Entropy // Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()] configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory share
if #available(macOS 13, *) {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
configuration.directorySharingDevices = [sharingDevice]
} else if !directoryShares.isEmpty {
throw UnsupportedOSError("directory sharing", "is")
}
try configuration.validate() try configuration.validate()
return configuration return configuration
@@ -297,3 +304,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal() sema.signal()
} }
} }
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+30 -36
View File
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case version case version
case ecid case os
case hardwareModel case arch
case cpuCountMin case cpuCountMin
case cpuCount case cpuCount
case memorySizeMin case memorySizeMin
case memorySize case memorySize
case macAddress case macAddress
case display case display
// macOS-specific keys
case ecid
case hardwareModel
} }
struct VMDisplayConfig: Codable { struct VMDisplayConfig: Codable {
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
struct VMConfig: Codable { struct VMConfig: Codable {
var version: Int = 1 var version: Int = 1
var ecid: VZMacMachineIdentifier var os: OS
var hardwareModel: VZMacHardwareModel var arch: Architecture
var platform: Platform
var cpuCountMin: Int var cpuCountMin: Int
private(set) var cpuCount: Int private(set) var cpuCount: Int
var memorySizeMin: UInt64 var memorySizeMin: UInt64
private(set) var memorySize: UInt64 private(set) var memorySize: UInt64
var macAddress: VZMACAddress var macAddress: VZMACAddress
var display: VMDisplayConfig = VMDisplayConfig() var display: VMDisplayConfig = VMDisplayConfig()
init( init(
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(), platform: Platform,
hardwareModel: VZMacHardwareModel, cpuCountMin: Int,
cpuCountMin: Int, memorySizeMin: UInt64,
memorySizeMin: UInt64, macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) { ) {
self.ecid = ecid self.os = platform.os()
self.hardwareModel = hardwareModel self.arch = CurrentArchitecture()
self.platform = platform
self.macAddress = macAddress self.macAddress = macAddress
self.cpuCountMin = cpuCountMin self.cpuCountMin = cpuCountMin
self.memorySizeMin = memorySizeMin self.memorySizeMin = memorySizeMin
@@ -81,29 +85,18 @@ struct VMConfig: Codable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decode(Int.self, forKey: .version) version = try container.decode(Int.self, forKey: .version)
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
let encodedECID = try container.decode(String.self, forKey: .ecid) arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
guard let data = Data.init(base64Encoded: encodedECID) else { switch os {
throw DecodingError.dataCorruptedError(forKey: .ecid, case .darwin:
in: container, platform = try Darwin(from: decoder)
debugDescription: "failed to initialize Data using the provided value") case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
} }
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .ecid,
in: container,
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
}
self.ecid = ecid
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
}
self.hardwareModel = hardwareModel
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin) cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount) cpuCount = try container.decode(Int.self, forKey: .cpuCount)
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin) memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
@@ -125,8 +118,9 @@ struct VMConfig: Codable {
var container = encoder.container(keyedBy: CodingKeys.self) var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version) try container.encode(version, forKey: .version)
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid) try container.encode(os, forKey: .os)
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel) try container.encode(arch, forKey: .arch)
try platform.encode(to: encoder)
try container.encode(cpuCountMin, forKey: .cpuCountMin) try container.encode(cpuCountMin, forKey: .cpuCountMin)
try container.encode(cpuCount, forKey: .cpuCount) try container.encode(cpuCount, forKey: .cpuCount)
try container.encode(memorySizeMin, forKey: .memorySizeMin) try container.encode(memorySizeMin, forKey: .memorySizeMin)
+1 -1
View File
@@ -142,7 +142,7 @@ extension VMDirectory {
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
// Craft a stub OCI config for Docker Hub compatibility // Craft a stub OCI config for Docker Hub compatibility
let ociConfigJSON = try OCIConfig().toJSON() let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest( let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
+1 -1
View File
@@ -29,7 +29,7 @@ struct VMDirectory: Prunable {
} }
static func temporary() throws -> VMDirectory { static func temporary() throws -> VMDirectory {
let tmpDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let tmpDir = try Config().tartTmpDir.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false) try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: false)
return VMDirectory(baseURL: tmpDir) return VMDirectory(baseURL: tmpDir)
+5 -1
View File
@@ -1,7 +1,7 @@
import Foundation import Foundation
class VMStorageLocal { class VMStorageLocal {
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true) let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL { private func vmURL(_ name: String) -> URL {
baseURL.appendingPathComponent(name, isDirectory: true) baseURL.appendingPathComponent(name, isDirectory: true)
@@ -32,6 +32,10 @@ class VMStorageLocal {
_ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL) _ = try FileManager.default.replaceItemAt(vmURL(name), withItemAt: from.baseURL)
} }
func rename(_ name: String, _ newName: String) throws {
_ = try FileManager.default.replaceItemAt(vmURL(newName), withItemAt: vmURL(name))
}
func delete(_ name: String) throws { func delete(_ name: String) throws {
try FileManager.default.removeItem(at: vmURL(name)) try FileManager.default.removeItem(at: vmURL(name))
} }
+36 -4
View File
@@ -1,12 +1,16 @@
import Foundation import Foundation
class VMStorageOCI: PrunableStorage { class VMStorageOCI: PrunableStorage {
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true) let baseURL = try! Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
private func vmURL(_ name: RemoteName) -> URL { private func vmURL(_ name: RemoteName) -> URL {
baseURL.appendingRemoteName(name) baseURL.appendingRemoteName(name)
} }
private func hostDirectoryURL(_ name: RemoteName) -> URL {
baseURL.appendingHost(name)
}
func exists(_ name: RemoteName) -> Bool { func exists(_ name: RemoteName) -> Bool {
VMDirectory(baseURL: vmURL(name)).initialized VMDirectory(baseURL: vmURL(name)).initialized
} }
@@ -125,17 +129,41 @@ class VMStorageOCI: PrunableStorage {
let digestName = RemoteName(host: name.host, namespace: name.namespace, let digestName = RemoteName(host: name.host, namespace: name.namespace,
reference: Reference(digest: Digest.hash(manifestData))) reference: Reference(digest: Digest.hash(manifestData)))
// Ensure that host directory for given RemoteName exists in OCI storage
let hostDirectoryURL = hostDirectoryURL(digestName)
try FileManager.default.createDirectory(at: hostDirectoryURL, withIntermediateDirectories: true)
// Acquire a lock on it to prevent concurrent pulls for a single host
let lock = try FileLock(lockURL: hostDirectoryURL)
let sucessfullyLocked = try lock.trylock()
if !sucessfullyLocked {
print("waiting for lock...")
try lock.lock()
}
defer { try! lock.unlock() }
if Task.isCancelled {
throw CancellationError()
}
if !exists(digestName) { if !exists(digestName) {
let tmpVMDir = try VMDirectory.temporary() let tmpVMDir = try VMDirectory.temporary()
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
try tmpVMDirLock.lock()
// Try to reclaim some cache space if we know the VM size in advance // Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() { if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024) let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) let attrs = try Config().tartCacheDir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey, .volumeAvailableCapacityKey])
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!) let availableCapacityBytes = max(UInt64(attrs.volumeAvailableCapacityForImportantUsage!), UInt64(attrs.volumeAvailableCapacity!))
if availableCapacityBytes < requiredCapacityBytes { // There is a suspicious that occasionally capacity is returned as zero which can't be true.
// Let's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes) try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
} }
} }
@@ -181,4 +209,8 @@ extension URL {
return result return result
} }
func appendingHost(_ name: RemoteName) -> URL {
self.appendingPathComponent(name.host, isDirectory: true)
}
} }
+34
View File
@@ -0,0 +1,34 @@
import XCTest
@testable import tart
final class FileLockTests: XCTestCase {
func testSimple() throws {
// Create a temporary file that will be used as a lock
let url = temporaryFile()
// Make sure this file can be locked and unlocked
let lock = try FileLock(lockURL: url)
try lock.lock()
try lock.unlock()
}
func testDoubleLockResultsInError() throws {
// Create a temporary file that will be used as a lock
let url = temporaryFile()
// Create two locks on a same file and ensure one of them fails
let firstLock = try FileLock(lockURL: url)
try firstLock.lock()
let secondLock = try! FileLock(lockURL: url)
XCTAssertFalse(try secondLock.trylock())
}
private func temporaryFile() -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: url.path, contents: nil)
return url
}
}
+12
View File
@@ -0,0 +1,12 @@
source = [".build/arm64-apple-macosx/debug/tart"]
bundle_id = "com.github.cirruslabs.tart"
apple_id {
username = "hello@cirruslabs.org"
password = "@env:AC_PASSWORD"
}
sign {
application_identity = "Developer ID Application: Cirrus Labs, Inc."
entitlements_file = "Resources/tart.entitlements"
}