mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 09:42:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9001304c8 | ||
|
|
4e20ea8f72 | ||
|
|
678ce0a55a | ||
|
|
8273ae66e1 | ||
|
|
4a9316a377 | ||
|
|
6321d547cf | ||
|
|
f241e21614 | ||
|
|
0eca923604 | ||
|
|
87f29cc11f | ||
|
|
90d1393137 | ||
|
|
625d431d10 | ||
|
|
4648e1aea1 | ||
|
|
4b62b73015 | ||
|
|
ae7018c31f | ||
|
|
e54c89da0c | ||
|
|
9a0ec3e6b0 | ||
|
|
400f85a493 | ||
|
|
0105280b5d | ||
|
|
c063c5bdc2 | ||
|
|
8a2b0151e0 | ||
|
|
2eea51d6ec | ||
|
|
1890909d28 | ||
|
|
0cad2e454c | ||
|
|
17dcf942c1 | ||
|
|
6296df7c0c | ||
|
|
c54b140750 | ||
|
|
048a5506df | ||
|
|
553b36349b | ||
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 |
Executable
+11
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
export VERSION="${CIRRUS_TAG:-0}"
|
||||||
|
|
||||||
|
mkdir -p .ci/pkg/
|
||||||
|
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
||||||
|
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Cirrus Labs, Inc. (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
||||||
|
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||||
|
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||||
+15
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
github: [cirruslabs]
|
||||||
+7
-2
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||

|

|
||||||
|
|
||||||
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon.
|
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dbcf6f65f26c0e17b2fe55a9712fc783315cd64905a0febce758b31d7e4e923b
|
||||||
|
size 6787
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4239996f30145992936cfc8faa0232fa8904aedf4de61e30ef4c4fc86cad587f
|
||||||
|
size 14588
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:227192fcd215c9cc05b1ebcd2616bd1f8c95be184726f64d91fc4507a88c66e1
|
||||||
|
size 18393
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
protocol Network {
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment
|
||||||
|
func run() throws
|
||||||
|
func stop() throws
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
class NetworkBridged: Network {
|
||||||
|
let interface: VZBridgedNetworkInterface
|
||||||
|
|
||||||
|
init(interface: VZBridgedNetworkInterface) {
|
||||||
|
self.interface = interface
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachment() -> VZNetworkDeviceAttachment {
|
||||||
|
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() throws {
|
||||||
|
// no-op, only used for Softnet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Architecture: String, Codable {
|
||||||
|
case arm64
|
||||||
|
case amd64
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentArchitecture() -> Architecture {
|
||||||
|
#if arch(arm64)
|
||||||
|
return .arm64
|
||||||
|
#elseif arch(x86_64)
|
||||||
|
return .amd64
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
struct Darwin: Platform {
|
||||||
|
var ecid: VZMacMachineIdentifier
|
||||||
|
var hardwareModel: VZMacHardwareModel
|
||||||
|
|
||||||
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
|
self.ecid = ecid
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||||
|
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize Data using the provided value")
|
||||||
|
}
|
||||||
|
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||||
|
in: container,
|
||||||
|
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||||
|
}
|
||||||
|
self.ecid = ecid
|
||||||
|
|
||||||
|
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||||
|
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||||
|
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||||
|
}
|
||||||
|
self.hardwareModel = hardwareModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||||
|
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func os() -> OS {
|
||||||
|
.darwin
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
VZMacOSBootLoader()
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||||
|
let result = VZMacPlatformConfiguration()
|
||||||
|
|
||||||
|
result.machineIdentifier = ecid
|
||||||
|
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||||
|
result.hardwareModel = hardwareModel
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
let result = VZMacGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
if let hostMainScreen = NSScreen.main {
|
||||||
|
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.displays = [
|
||||||
|
VZMacGraphicsDisplayConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height,
|
||||||
|
// A reasonable guess according to Apple's documentation[1]
|
||||||
|
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||||
|
pixelsPerInch: 72
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
|
if #available(macOS 13, *) {
|
||||||
|
// Trackpad is only supported starting with macOS Ventura
|
||||||
|
// macOS Monterey will continue using a USB device == .darwin
|
||||||
|
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
} else {
|
||||||
|
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
@available(macOS 13, *)
|
||||||
|
struct Linux: Platform {
|
||||||
|
func os() -> OS {
|
||||||
|
.linux
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
|
let result = VZEFIBootLoader()
|
||||||
|
|
||||||
|
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||||
|
VZGenericPlatformConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
|
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
|
result.scanouts = [
|
||||||
|
VZVirtioGraphicsScanoutConfiguration(
|
||||||
|
widthInPixels: vmConfig.display.width,
|
||||||
|
heightInPixels: vmConfig.display.height
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
enum OS: String, Codable {
|
||||||
|
case darwin
|
||||||
|
case linux
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import Virtualization
|
||||||
|
|
||||||
|
protocol Platform: Codable {
|
||||||
|
func os() -> OS
|
||||||
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
|
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||||
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
source = [".build/arm64-apple-macosx/debug/tart"]
|
||||||
|
bundle_id = "com.github.cirruslabs.tart"
|
||||||
|
|
||||||
|
apple_id {
|
||||||
|
username = "hello@cirruslabs.org"
|
||||||
|
password = "@env:AC_PASSWORD"
|
||||||
|
}
|
||||||
|
|
||||||
|
sign {
|
||||||
|
application_identity = "Developer ID Application: Cirrus Labs, Inc."
|
||||||
|
entitlements_file = "Resources/tart.entitlements"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user