Compare commits

..
30 Commits
Author SHA1 Message Date
Nikolay Edigaryev 31635e59d7 Don't swallow VZVirtualMachine.start() exceptions (#343)
* Don't swallow VZVirtualMachine.start() exceptions

* Rename task to startTask for clarity

* No need to use "self"
2022-12-01 14:03:21 -05:00
Nikolay EdigaryevandFedor Korotkov e061d00afc Fix SwiftFormat's URL/glob mishandling (#342)
* Fix SwiftFormat's URL/glob mishandling

* Revert "Fix SwiftFormat's URL/glob mishandling"

This reverts commit 4d1a4c7fb3.

* Use a fixed SwiftFormat

* Another SwiftFormat fix

* Update .cirrus.yml

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-11-30 17:53:25 +00:00
Nikolay Edigaryev 4555dd5824 README.md: document --dir (#338) 2022-11-30 18:12:06 +04:00
Nikolay Edigaryev ad9c3c661e Reformat code idents and introduce the SwiftFormat linter (#339)
* Package.swift: add SwiftFormat

Can be invoked with "swift package plugin swiftformat".

* $ swift package plugin swiftformat

* .cirrus.yml: run SwiftFormat

* SwiftFormat: exclude Sources/tart/OCI/Reference/Generated
2022-11-29 15:56:13 +00:00
Nikolay Edigaryev c27d4a089c OCI: WWW-Authenticate's scheme should be treated case-insensitive (#336) 2022-11-25 16:59:38 +04:00
Nikolay Edigaryev 70f9fcc12e tart run: do not check --disk lock for read-only attachments (#334) 2022-11-22 22:37:48 -05:00
Nikolay Edigaryev 456ebc1c7b Rosetta support (#324) 2022-11-21 12:56:27 -05:00
Nikolay Edigaryev 14cbc727ad tart run: check if the disks are locked (#333) 2022-11-21 17:42:53 +04:00
Fedor Korotkov 03e6d9345d Document list of users (#332)
Fixes #208
2022-11-21 12:34:24 +04:00
Nikolay Edigaryev c09cbefdd0 VZVirtualMachineDelegate implementation: show the error details (#330) 2022-11-18 18:51:26 +04:00
Fedor Korotkov 3896728eb9 Added Code Owners (#328)
* Added Code Owners

* Rename gi to CODEOWNERS
2022-11-17 22:57:12 -05:00
sheldonneuberger-sc de993fbf6d use 64bit int for memory (#327) 2022-11-16 14:52:28 -05:00
Nikolay Edigaryev f08ddf3855 Fetch IPSWs via file (#322) 2022-11-15 10:01:52 +04:00
Nikolay Edigaryev 8524d93741 Integration tests (#313)
* Integration tests

* Set "HOMEBREW_NO_AUTO_UPDATE=1" for virtualenv installation step

* Use CIRRUS_WORKING_DIR as temporary directory if present
2022-11-14 13:24:51 -05:00
Nikolay Edigaryev 833c162187 tart stop: return a different exit code when VM is not running (#321)
See https://github.com/cirruslabs/tart/pull/316#issuecomment-1311556674.
2022-11-11 14:20:21 -05:00
Fedor Korotkov 31ba71dad7 Option to provide registry credentials via environment variables (#320)
Fixes #124
2022-11-11 18:32:23 +04:00
Nikolay Edigaryev 5e77968989 Introduce "tart stop" (#316) 2022-11-11 07:59:22 +04:00
Evan Burkey f37372da28 Implement Get command (#309) 2022-11-10 09:14:14 +04:00
Nikolay Edigaryev e600d2f036 Improve UninitializedVMDirectoryError() (#314) 2022-11-09 20:49:35 -05:00
Nikolay Edigaryev b21dbbe3a3 Fetcher: do not use cookies to avoid CSRF checks (#312)
* Fetcher: do not use cookies to avoid CSRF checks

* Explain why we disable cookies

* Rename getURLSession() → createURLSession() and move it below
2022-11-09 14:27:45 -05:00
Nikolay Edigaryev ee0fbdd83d OCI: pull blobs via file (#306)
* OCI: pull blobs via file

* Explain why we delete the downloaded file after opening a handle to it

* Further abstract away ways to fetch a URLRequest

* No need to cast HTTPURLResponse to HTTPURLResponse

* Fetcher: no need to be a delegate anymore

* Fetcher.fetch() can be made static
2022-11-09 19:36:41 +04:00
Fedor Korotkov 8961c5189a Fixed Tart logo on GitHubMobile App (#310)
We can't use the regular images since we use Git LFS. Forgot to fix the main image as part of #237
2022-11-09 12:19:15 +04:00
Nikolay EdigaryevandFedor Korotkov 555715588d README.md: more realistic Packer HCL example (#308)
* README.md: more realistic Packer HCL example

* Update README.md

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

* Update README.md

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2022-11-08 04:59:35 -05:00
Andrea Cristalli 8d096966b4 Packer json configuration is deprecated (#307) 2022-11-08 13:42:22 +04:00
Nikolay Edigaryev fb954b7cc1 tart list: support -q (or --quiet) for automation purposes (#293) 2022-11-02 11:49:45 -04:00
Fedor KorotkovandNikolay Edigaryev 8cbcd2285b Document Cirrus Runners (#288)
* Document Cirrus Runners

Fixes #237

* Update README.md

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>

* Remove redundant mention

Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
2022-10-27 10:17:13 -04:00
Pete Goldsmith 3d0d889c99 Remove whitespace (#285) 2022-10-22 08:17:42 -04:00
Nikolay Edigaryev 0e77f14dd7 OCI: always read channel until end (#284) 2022-10-18 11:44:14 -04:00
Nikolay Edigaryev 39e1b84423 Use URLSession.dataTask() with delegate instead of URLSession.bytes() (#282)
* Use URLSession.dataTask() with delegate instead of URLSession.bytes()

* Use URLSession.shared instead of creating a new one each time
2022-10-18 08:54:28 -04:00
Fedor Korotkov af7530ee50 Relax status code acceptance (#281)
* Relax status code acceptance

Fixes #273

* added a comment
2022-10-17 21:42:25 +04:00
66 changed files with 1453 additions and 772 deletions
+38 -3
View File
@@ -1,13 +1,44 @@
use_compute_credits: true
task:
name: Test on Ventura
alias: test
persistent_worker:
labels:
name: Mac-Mini-M1
build_script: swift test
test_script: swift test
name: scaleway-m1
test_script:
- swift test
integration_test_script:
# Build Tart
- swift build
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
# Run integration tests
- cd integration-tests
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
task:
name: Lint
alias: lint
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
lint_script:
- swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore --lint --report swiftformat.json .
always:
swiftformat_report_artifacts:
path: swiftformat.json
format: swiftformat
task:
name: Build
alias: build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
@@ -19,6 +50,10 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- lint
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env:
+1
View File
@@ -0,0 +1 @@
* @edigaryev @fkorotkov
+5
View File
@@ -0,0 +1,5 @@
--disable all
--enable indent
--indent 2
--exclude Sources/tart/OCI/Reference/Generated
--swiftversion 5.7
+8
View File
@@ -98,6 +98,14 @@
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
"version" : "6.3.1"
}
},
{
"identity" : "swiftformat",
"kind" : "remoteSourceControl",
"location" : "https://github.com/edigaryev/SwiftFormat",
"state" : {
"revision" : "46f34946546a29eeb6cb23ec44936b74fd379d63"
}
}
],
"version" : 2
+1
View File
@@ -18,6 +18,7 @@ let package = Package(
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
.package(url: "https://github.com/antlr/antlr4", branch: "dev"),
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
.package(url: "https://github.com/edigaryev/SwiftFormat", branch: "46f34946546a29eeb6cb23ec44936b74fd379d63"),
],
targets: [
.executableTarget(name: "tart", dependencies: [
+128 -31
View File
@@ -1,4 +1,4 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
*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:
@@ -22,20 +22,64 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
</a>
</p>
Many more companies are using Tart in their internal setups. Here are a few of them:
<p align="center">
<a href="https://ahrefs.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/ahrefs.png" height="65"/>
</a>
<a href="https://suran.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Suran.png" height="65"/>
</a>
<a href="https://symflower.com/" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/Users/Symflower.png" height="65"/>
</a>
</p>
**Note:** If your company or project is using Tart please consider [adding yourself to the list above](/Resources/Users/HowToAddYourself.md).
## Usage
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
```shell
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
tart run monterey-base
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
```
![tart VM view app](Resources/TartScreenshot.png)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
## CI Integration
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
Please read down below about currently supported services.
### Managed runners for your CI-as-a-service
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
#### GitHub Actions
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
### Self-hosted CI
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
@@ -64,14 +108,14 @@ brew install cirruslabs/cli/cirrus
cirrus run
```
![Cirrus CLI Run](Resources/TartCirrusCLI.gif)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
### Retrieving artifacts from within Tart VMs
#### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
@@ -152,32 +196,35 @@ Please refer to `tart set --help` for additional details.
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
Here is an example of a template to build `monterey-base` local image based of a remote image:
```json
{
"builders": [
{
"name": "tart",
"type": "tart-cli",
"vm_base_name": "tartvm/vanilla:latest",
"vm_name": "monterey-base",
"cpu_count": 4,
"memory_gb": 8,
"disk_size_gb": 32,
"ssh_username": "admin",
"ssh_password": "admin",
"ssh_timeout": "120s"
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
],
"provisioners": [
{
"inline": [
"echo 'Disabling spotlight indexing...'",
"sudo mdutil -a -i off"
],
"type": "shell"
},
# more provisioners
]
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
@@ -197,6 +244,12 @@ tart login acme.io
Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
#### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
@@ -221,6 +274,50 @@ 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.
### Mounting directories
To mount a directory, run the VM with the `--dir` argument:
```sh
tart run --dir=project:~/src/project vm
```
Here, the `project` specifies a mount name, whereas the `~/src/project` is a path to the host's directory to expose to the VM.
It is also possible to mount directories in read-only mode by adding a third parameter, `ro`:
```sh
tart run --dir=project:~/src/project:ro vm
```
To mount multiple directories, repeat the `--dir` argument for each directory:
```sh
tart run --dir=www1:~/project1/www --dir=www2:~/project2/www
```
Note that the first parameter in each `--dir` argument must be unique, otherwise only the last `--dir` argument using that name will be used.
Note: to use the directory mounting feature, the host needs to run macOS 13.0 (Ventura) or newer.
#### Accessing mounted directories in macOS guests
All shared directories are automatically mounted to `/Volumes/My Shared Files` directory.
The directory we've mounted above will be accessible from the `/Volumes/My Shared Files/project` path inside a guest VM.
Note: to use the directory mounting feature, the guest VM needs to run macOS 13.0 (Ventura) or newer.
#### Accessing mounted directories in Linux guests
To be able to access the shared directories from the Linux guest, you need to manually mount the virtual filesystem first:
```sh
mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
```
The directory we've mounted above will be accessible from the `/mnt/shared/project` path inside a guest VM.
## FAQ
<details>
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
size 155885
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
size 205325
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
size 1359834
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4d1f58f6460217434ae133c6d6e444320eb705c5dcf9efebbb215b117ae452c1
size 589
+4
View File
@@ -0,0 +1,4 @@
If you'd like to highlight your use of Tart, please create a `456px` by `130px` logo and create a PR
that adds it to `README.md` in alphabetical order. Don't forget to include a small description of your usage pattern.
You can refer to `Background.png` as a base for your logo.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:630c4975b9eead9e4f62e2a5375509dc0ee6cab2940ec3ffc2ae16b08d325964
size 6765
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5e48fbe90f6cc84e100fc286a5349c681cacb22358811181046d35ea298309de
size 16379
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1c7f7f9bf0fc0c9807f3a415fe6bf773c8a416b351c5aa08de46149a13de7387
size 5297
+31
View File
@@ -0,0 +1,31 @@
import ArgumentParser
import Foundation
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name")
var name: String
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
print("CPU\tMemory\tDisk\tDisplay")
var s = "\(vmConfig.cpuCount)\t"
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
s += "\(diskSize) GB\t"
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
print(s)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+11 -2
View File
@@ -5,9 +5,14 @@ import SwiftUI
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
var quiet: Bool = false
func run() async throws {
do {
print("Source\tName")
if !quiet {
print("Source\tName")
}
displayTable("local", try VMStorageLocal().list())
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
@@ -22,7 +27,11 @@ struct List: AsyncParsableCommand {
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
print("\(source)\t\(name)")
if quiet {
print(name)
} else {
print("\(source)\t\(name)")
}
}
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
print("invalid credentials: \(error)")
+8 -8
View File
@@ -7,13 +7,13 @@ struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n"))
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n"))
var olderThan: UInt?
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
var cacheBudget: UInt?
@Flag(help: .hidden)
@@ -62,8 +62,8 @@ struct Prune: AsyncParsableCommand {
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes
var prunablesToDelete: [Prunable] = []
@@ -87,8 +87,8 @@ struct Prune: AsyncParsableCommand {
static func pruneReclaim(reclaimBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }
// Does it even make sense to start?
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
+7 -7
View File
@@ -16,15 +16,15 @@ struct Push: AsyncParsableCommand {
var insecure: Bool = false
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
"""))
discussion: """
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
"""))
var chunkSize: Int = 0
@Flag(help: ArgumentHelp("cache pushed images locally",
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
var populateCache: Bool = false
func run() async throws {
@@ -49,7 +49,7 @@ struct Push: AsyncParsableCommand {
// Push VM
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
insecure: insecure)
insecure: insecure)
defaultLogger.appendNewLine("pushing \(localName) to "
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
+24 -24
View File
@@ -2,39 +2,39 @@ import ArgumentParser
import Foundation
struct Rename: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Rename a VM")
static var configuration = CommandConfiguration(abstract: "Rename a VM")
@Argument(help: "VM name")
var name: String
@Argument(help: "VM name")
var name: String
@Argument(help: "new VM name")
var newName: 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 validate() throws {
if newName.contains("/") {
throw ValidationError("<new-name> should be a local name")
}
}
func run() async throws {
do {
let localStorage = VMStorageLocal()
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(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!")
}
if localStorage.exists(newName) {
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
}
try localStorage.rename(name, newName)
try localStorage.rename(name, newName)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
Foundation.exit(1)
}
}
}
+139 -44
View File
@@ -15,8 +15,8 @@ struct Run: AsyncParsableCommand {
var name: String
@Flag(help: ArgumentHelp(
"Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
"Don't open a UI window.",
discussion: "Useful for integrating Tart VMs into other tools.\nUse `tart ip` in order to get an IP for SSHing or VNCing into the VM."))
var noGraphics: Bool = false
@Flag(help: "Force open a UI window, even when VNC is enabled.")
@@ -24,11 +24,11 @@ struct Run: AsyncParsableCommand {
@Flag(help: "Boot into recovery mode")
var recovery: Bool = false
@Flag(help: ArgumentHelp(
"Use screen sharing instead of the built-in UI.",
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
"Use screen sharing instead of the built-in UI.",
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
+ "Note that Remote Login option should be enabled inside the VM."))
var vnc: Bool = false
@Flag(help: ArgumentHelp(
@@ -41,32 +41,46 @@ struct Run: AsyncParsableCommand {
var withSoftnet: Bool = false
@Option(help: ArgumentHelp("""
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""", valueName: "path[:ro]"))
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
""", discussion: """
Learn how to create a disk image using Disk Utility here:
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
""", valueName: "path[:ro]"))
var disk: [String] = []
@Option(name: [.customLong("rosetta")], help: ArgumentHelp(
"Attaches a Rosetta share to the guest Linux VM with a specific tag (e.g. --rosetta=\"rosetta\")",
discussion: """
Requires host to be macOS 13.0 (Ventura) with Rosetta installed. The latter can be done
by running "softwareupdate --install-rosetta" (without quotes) in the Terminal.app.
Note that you also have to configure Rosetta in the guest Linux VM by following the
steps from "Mount the Shared Directory and Register Rosetta" section here:
https://developer.apple.com/documentation/virtualization/running_intel_binaries_in_linux_vms_with_rosetta#3978496
""",
valueName: "tag"
))
var rosettaTag: 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]"))
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"))
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
var netSoftnet: Bool = false
func validate() throws {
@@ -90,11 +104,29 @@ struct Run: AsyncParsableCommand {
@MainActor
func run() async throws {
let vmDir = try VMStorageLocal().open(name)
let additionalDiskAttachments = try additionalDiskAttachments()
// Error out if the disk is locked by the host (e.g. it was mounted in Finder),
// see https://github.com/cirruslabs/tart/issues/323 for more details.
for additionalDiskAttachment in additionalDiskAttachments {
// Read-only attachments do not seem to acquire the lock
if additionalDiskAttachment.isReadOnly {
continue
}
if try !FileLock(lockURL: additionalDiskAttachment.url).trylock() {
print("disk \(additionalDiskAttachment.url.path) seems to be already in use, "
+ "unmount it first in Finder")
Foundation.exit(1)
}
}
vm = try VM(
vmDir: vmDir,
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
additionalDiskAttachments: additionalDiskAttachments(),
directoryShares: directoryShares()
additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
)
let vncImpl: VNC? = try {
@@ -108,6 +140,24 @@ struct Run: AsyncParsableCommand {
}
}()
// Lock the VM
//
// More specifically, lock the "config.json", because we can't lock
// directories with fcntl(2)-based locking and we better not interfere
// with the VM's disk and NVRAM, because they are opened (and even seem
// to be locked) directly by the Virtualization.Framework's process.
//
// Note that due to "completely stupid semantics"[1] of the fcntl-based
// file locking, we need to acquire the lock after we read the VM's
// configuration file, otherwise we will loose the lock.
//
// [1]: https://man.openbsd.org/fcntl
let lock = try PIDLock(lockURL: vmDir.configURL)
if try !lock.trylock() {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
}
let task = Task {
do {
if let vncImpl = vncImpl {
@@ -129,11 +179,6 @@ struct Run: AsyncParsableCommand {
Foundation.exit(0)
} catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
}
print(error)
Foundation.exit(1)
}
@@ -216,8 +261,22 @@ struct Run: AsyncParsableCommand {
return result
}
func directoryShares() throws -> [DirectoryShare] {
var result: [DirectoryShare] = []
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
if dir.isEmpty {
return []
}
guard #available(macOS 13, *) else {
throw UnsupportedOSError("directory sharing", "is")
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
var directoryShares: [DirectoryShare] = []
for rawDir in dir {
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
@@ -238,10 +297,46 @@ struct Run: AsyncParsableCommand {
let (name, path) = (String(splits[0]), String(splits[1]))
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath), readOnly: readOnly))
directoryShares.append(DirectoryShare(
name: name,
path: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath),
readOnly: readOnly)
)
}
return result
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)
return [sharingDevice]
}
private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] {
guard let rosettaTag = rosettaTag else {
return []
}
guard #available(macOS 13, *) else {
throw UnsupportedOSError("Rosetta directory share", "is")
}
switch VZLinuxRosettaDirectoryShare.availability {
case .notInstalled:
throw UnsupportedOSError("Rosetta directory share", "is", "that have Rosetta installed")
case .notSupported:
throw UnsupportedOSError("Rosetta directory share", "is", "running Apple silicon")
default:
break
}
try VZVirtioFileSystemDeviceConfiguration.validateTag(rosettaTag)
let device = VZVirtioFileSystemDeviceConfiguration(tag: rosettaTag)
device.share = try VZLinuxRosettaDirectoryShare()
return [device]
}
private func runUI() {
@@ -262,16 +357,16 @@ struct Run: AsyncParsableCommand {
}
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
}.commands {
// Remove some standard menu options
CommandGroup(replacing: .help, addition: {})
CommandGroup(replacing: .newItem, addition: {})
CommandGroup(replacing: .pasteboard, addition: {})
CommandGroup(replacing: .textEditing, addition: {})
CommandGroup(replacing: .undoRedo, addition: {})
CommandGroup(replacing: .windowSize, addition: {})
// Replace some standard menu options
CommandGroup(replacing: .appInfo) { AboutTart() }
}
// Remove some standard menu options
CommandGroup(replacing: .help, addition: {})
CommandGroup(replacing: .newItem, addition: {})
CommandGroup(replacing: .pasteboard, addition: {})
CommandGroup(replacing: .textEditing, addition: {})
CommandGroup(replacing: .undoRedo, addition: {})
CommandGroup(replacing: .windowSize, addition: {})
// Replace some standard menu options
CommandGroup(replacing: .appInfo) { AboutTart() }
}
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
var cpu: UInt16?
@Option(help: "VM memory size in megabytes")
var memory: UInt16?
var memory: UInt64?
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
}
if let memory = memory {
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
+72
View File
@@ -0,0 +1,72 @@
import ArgumentParser
import Foundation
import System
import SwiftDate
struct Stop: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
@Argument(help: "VM name")
var name: String
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
var timeout: UInt64 = 30
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID
var pid = try lock.pid()
if pid == 0 {
print("VM \(name) is not running")
Foundation.exit(2)
}
// Try to gracefully terminate the VM
//
// Note that we don't check the return code here
// to provide a clean exit from "tart stop" in cases
// when the VM is already shutting down and we hit
// a race condition.
//
// We check the return code in the kill(2) below, though,
// because it's a less common scenario and it would be
// nice to know for the user that we've tried all methods
// and failed to shutdown the VM.
kill(pid, SIGINT)
// Ensure that the VM has terminated
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
while gracefulWaitDuration.value > 0 {
pid = try lock.pid()
if pid == 0 {
Foundation.exit(0)
}
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
}
// Seems that VM is still running, proceed with forceful termination
let ret = kill(pid, SIGKILL)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
print("failed to forcefully terminate the VM \(name): \(details)")
Foundation.exit(1)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+3 -3
View File
@@ -12,8 +12,8 @@ struct Config {
tartHomeDir = URL(fileURLWithPath: customTartHome)
} else {
tartHomeDir = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
.homeDirectoryForCurrentUser
.appendingPathComponent(".tart", isDirectory: true)
}
self.tartHomeDir = tartHomeDir
@@ -26,7 +26,7 @@ struct Config {
func gc() throws {
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
includingPropertiesForKeys: [], options: []) {
includingPropertiesForKeys: [], options: []) {
let lock = try FileLock(lockURL: entry)
if try !lock.trylock() {
continue
@@ -1,10 +1,10 @@
import Foundation
enum CredentialsProviderError: Error {
case Failed(message: String)
case Failed(message: String)
}
protocol CredentialsProvider {
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
func retrieve(host: String) throws -> (String, String)?
func store(host: String, user: String, password: String) throws
}
@@ -22,14 +22,14 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
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
@@ -38,7 +38,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
inPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
@@ -0,0 +1,15 @@
import Foundation
class EnvironmentCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
if let username = username, let password = password {
return (username, password)
}
return nil
}
func store(host: String, user: String, password: String) throws {
}
}
@@ -1,66 +1,66 @@
import Foundation
class KeychainCredentialsProvider: CredentialsProvider {
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
func retrieve(host: String) throws -> (String, String)? {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnAttributes as String: true,
kSecReturnData as String: true,
kSecAttrLabel as String: "Tart Credentials",
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
if status != errSecSuccess {
if status == errSecItemNotFound {
return nil
}
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
guard let item = item as? [String: Any],
let user = item[kSecAttrAccount as String] as? String,
let passwordData = item[kSecValueData as String] as? Data,
let password = String(data: passwordData, encoding: .utf8)
else {
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
}
return (user, password)
}
func store(host: String, user: String, password: String) throws {
let passwordData = password.data(using: .utf8)
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let value: [String: Any] = [kSecAttrAccount as String: user,
kSecValueData as String: passwordData,
]
let status = SecItemCopyMatching(key as CFDictionary, nil)
switch status {
case errSecItemNotFound:
let status = SecItemAdd(key.merging(value) { (current, _) in current } as CFDictionary, nil)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to add item: \(status.explanation())")
}
case errSecSuccess:
let status = SecItemUpdate(key as CFDictionary, value as CFDictionary)
if status != errSecSuccess {
throw CredentialsProviderError.Failed(message: "Keychain failed to update item: \(status.explanation())")
}
default:
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
}
extension OSStatus {
+67
View File
@@ -0,0 +1,67 @@
import Foundation
import AsyncAlgorithms
fileprivate let urlSession = createURLSession()
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
if viaFile {
return try await fetchViaFile(request)
}
return try await fetchViaMemory(request)
}
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (data, response) = try await urlSession.data(for: request)
Task {
await dataCh.send(data)
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (fileURL, response) = try await urlSession.download(for: request)
// Acquire a handle to the downloaded file and then remove it.
//
// This keeps a working reference to that file, yet we don't
// have to deal with the cleanup any more.
let fh = try FileHandle(forReadingFrom: fileURL)
try FileManager.default.removeItem(at: fileURL)
Task {
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
await dataCh.send(data)
}
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
}
fileprivate func createURLSession() -> URLSession {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}
+37 -37
View File
@@ -2,47 +2,47 @@ import Foundation
import System
enum FileLockError: Error, Equatable {
case Failed(_ message: String)
case AlreadyLocked
case Failed(_ message: String)
case AlreadyLocked
}
class FileLock {
let url: URL
let fd: Int32
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, 0)
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)")
}
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
}
return true
}
}
+1 -1
View File
@@ -15,6 +15,6 @@ class IPSWCache: PrunableStorage {
func prunables() throws -> [Prunable] {
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
}
}
+90 -90
View File
@@ -3,117 +3,117 @@ import Network
import Virtualization
struct ARPCommandFailedError: Error, CustomStringConvertible {
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var terminationReason: Process.TerminationReason
var terminationStatus: Int32
var description: String {
var reason: String
var description: String {
var reason: String
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
switch terminationReason {
case .exit:
reason = "exit code \(terminationStatus)"
case .uncaughtSignal:
reason = "uncaught signal"
default:
reason = "unknown reason"
}
return "arp command failed: \(reason)"
}
}
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
var explanation: String
var explanation: String
var description: String {
"arp command yielded invalid output: \(explanation)"
}
var description: String {
"arp command yielded invalid output: \(explanation)"
}
}
struct ARPCacheInternalError: Error, CustomStringConvertible {
var explanation: String
var explanation: String
var description: String {
"ARPCache internal error: \(explanation)"
}
var description: String {
"ARPCache internal error: \(explanation)"
}
}
struct ARPCache {
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
let process = Process.init()
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
process.arguments = ["-an"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
process.standardInput = FileHandle.nullDevice
try process.run()
process.waitUntilExit()
try process.run()
process.waitUntilExit()
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
if rawMAC == "(incomplete)" {
continue
}
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
let interface = try match.getCaptureGroup(name: "interface", for: line)
if bridgeOnly && !interface.starts(with: "bridge") {
continue
}
if macAddress == mac {
return ip
}
}
return nil
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
throw ARPCommandFailedError(
terminationReason: process.terminationReason,
terminationStatus: process.terminationStatus)
}
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
}
let lines = String(decoding: rawLines, as: UTF8.self)
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: "\n")
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
for line in lines {
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
}
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
guard let ip = IPv4Address(rawIP) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
}
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
if rawMAC == "(incomplete)" {
continue
}
guard let mac = MACAddress(fromString: rawMAC) else {
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
}
let interface = try match.getCaptureGroup(name: "interface", for: line)
if bridgeOnly && !interface.starts(with: "bridge") {
continue
}
if macAddress == mac {
return ip
}
}
return nil
}
}
extension NSTextCheckingResult {
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
func getCaptureGroup(name: String, for string: String) throws -> String {
let nsRange = self.range(withName: name)
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
if nsRange.location == NSNotFound {
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
}
guard let range = Range.init(nsRange, in: string) else {
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
}
return String(string[range])
}
}
+13 -13
View File
@@ -2,21 +2,21 @@ import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
let interface: VZBridgedNetworkInterface
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
}
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
+9 -9
View File
@@ -2,15 +2,15 @@ import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
}
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func run(_ sema: DispatchSemaphore) throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
func stop() async throws {
// no-op, only used for Softnet
}
}
+3 -3
View File
@@ -20,8 +20,8 @@ class Digest {
extension SHA256.Digest {
func hexdigest() -> String {
"sha256:" + self.map {
String(format: "%02x", $0)
}
.joined()
String(format: "%02x", $0)
}
.joined()
}
}
+45 -47
View File
@@ -2,8 +2,6 @@ import Foundation
import Algorithms
import AsyncAlgorithms
let chunkSizeBytes = 1 * 1024 * 1024
enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
case MissingLocationHeader
@@ -31,11 +29,11 @@ extension Data {
}
}
extension URLSession.AsyncBytes {
extension AsyncThrowingChannel<Data, Error> {
func asData() async throws -> Data {
var result = Data()
for try await chunk in chunks(ofCount: chunkSizeBytes) {
for try await chunk in self {
result += chunk
}
@@ -102,7 +100,7 @@ class Registry {
init(urlComponents: URLComponents,
namespace: String,
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
@@ -113,7 +111,7 @@ class Registry {
host: String,
namespace: String,
insecure: Bool = false,
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
@@ -132,11 +130,11 @@ class Registry {
let manifestJSON = try manifest.toJSON()
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Content-Type": manifest.mediaType],
body: manifestJSON)
headers: ["Content-Type": manifest.mediaType],
body: manifestJSON)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
details: data.asText())
details: data.asText())
}
return Digest.hash(manifestJSON)
@@ -144,10 +142,10 @@ class Registry {
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
headers: ["Accept": ociManifestMediaType])
headers: ["Accept": ociManifestMediaType])
if response.statusCode != HTTPCode.Ok.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
details: data.asText())
details: data.asText())
}
let manifest = try OCIManifest(fromJSON: data)
@@ -170,17 +168,17 @@ class Registry {
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
// Initiate a blob upload
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
headers: ["Content-Length": "0"])
headers: ["Content-Length": "0"])
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
details: data.asText())
details: data.asText())
}
// Figure out where to upload the blob
var uploadLocation = try uploadLocationFromResponse(postResponse)
let digest = Digest.hash(fromData)
if chunkSizeMb == 0 {
// monolithic upload
let (data, response) = try await dataRequest(
@@ -194,7 +192,7 @@ class Registry {
)
if response.statusCode != HTTPCode.Created.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
code: response.statusCode, details: data.asText())
code: response.statusCode, details: data.asText())
}
return digest
}
@@ -214,28 +212,28 @@ class Registry {
parameters: lastChunk ? ["digest": digest] : [:],
body: chunk
)
let expectedStatus = lastChunk ? HTTPCode.Created.rawValue : HTTPCode.Accepted.rawValue
if response.statusCode != expectedStatus {
// always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText())
code: response.statusCode, details: data.asText())
}
uploadedBytes += chunk.count
// Update location for the next chunk
uploadLocation = try uploadLocationFromResponse(response)
}
return digest
}
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
let (bytes, response) = try await bytesRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"))
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await bytes.asData().asText()
let body = try await channel.asData().asText()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body)
details: body)
}
for try await part in bytes.chunks(ofCount: chunkSizeBytes) {
for try await part in channel {
try Task.checkCancellation()
try handler(Data(part))
@@ -249,27 +247,28 @@ class Registry {
}
private func dataRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true
) async throws -> (Data, HTTPURLResponse) {
let (bytes, response) = try await bytesRequest(method, urlComponents,
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
return (try await bytes.asData(), response)
}
private func bytesRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true
) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
) async throws -> (Data, HTTPURLResponse) {
let (channel, response) = try await channelRequest(method, urlComponents,
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
return (try await channel.asData(), response)
}
private func channelRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true,
viaFile: Bool = false
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var urlComponents = urlComponents
if urlComponents.queryItems == nil && !parameters.isEmpty {
@@ -294,14 +293,15 @@ class Registry {
currentAuthToken = nil
}
var (bytes, response) = try await authAwareRequest(request: request)
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData()
try await auth(response: response)
(bytes, response) = try await authAwareRequest(request: request)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
}
return (bytes, response)
return (channel, response)
}
private func auth(response: HTTPURLResponse) async throws {
@@ -312,7 +312,7 @@ class Registry {
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
if wwwAuthenticate.scheme == "Basic" {
if wwwAuthenticate.scheme.lowercased() == "basic" {
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
currentAuthToken = BasicAuthentication(user: user, password: password)
}
@@ -320,7 +320,7 @@ class Registry {
return
}
if wwwAuthenticate.scheme != "Bearer" {
if wwwAuthenticate.scheme.lowercased() != "bearer" {
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
}
@@ -373,7 +373,7 @@ class Registry {
return nil
}
private func authAwareRequest(request: URLRequest) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var request = request
if let token = currentAuthToken {
@@ -381,8 +381,6 @@ class Registry {
request.addValue(value, forHTTPHeaderField: name)
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
return (bytes, response as! HTTPURLResponse)
return try await Fetcher.fetch(request, viaFile: viaFile)
}
}
+52
View File
@@ -0,0 +1,52 @@
import Foundation
import System
class PIDLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, O_RDWR)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
return locked
}
func lock() throws {
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
}
func unlock() throws {
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
}
func pid() throws -> pid_t {
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
return result.l_pid
}
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
let ret = fcntl(fd, operation, &result)
if ret != 0 {
if operation == F_SETLK && errno == EAGAIN {
return (false, result)
}
let details = Errno(rawValue: CInt(errno))
throw RuntimeError("\(message): \(details)")
}
return (true, result)
}
}
@@ -1,13 +1,13 @@
import Foundation
struct PassphraseGenerator: Sequence {
func makeIterator() -> PassphraseIterator {
PassphraseIterator()
}
func makeIterator() -> PassphraseIterator {
PassphraseIterator()
}
}
struct PassphraseIterator: IteratorProtocol {
mutating func next() -> String? {
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
}
mutating func next() -> String? {
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
}
}
+4 -4
View File
@@ -24,7 +24,7 @@ let passphrases = [
"act",
"action",
"actor",
" actress",
"actress",
"actual",
"adapt",
"add",
@@ -829,7 +829,7 @@ let passphrases = [
"grow",
"grunt",
"guard",
" guess",
"guess",
"guide",
"guilt",
"guitar",
@@ -1476,7 +1476,7 @@ let passphrases = [
"retire",
"retreat",
"return",
" reunion",
"reunion",
"reveal",
"review",
"reward",
@@ -1764,7 +1764,7 @@ let passphrases = [
"swim",
"swing",
"switch",
" sword",
"sword",
"symbol",
"symptom",
"syrup",
+7 -7
View File
@@ -1,14 +1,14 @@
import Foundation
enum Architecture: String, Codable {
case arm64
case amd64
case arm64
case amd64
}
func CurrentArchitecture() -> Architecture {
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
#if arch(arm64)
return .arm64
#elseif arch(x86_64)
return .amd64
#endif
}
+87 -87
View File
@@ -1,97 +1,97 @@
import Virtualization
struct Darwin: Platform {
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
var ecid: VZMacMachineIdentifier
var hardwareModel: VZMacHardwareModel
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
self.ecid = ecid
self.hardwareModel = hardwareModel
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
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
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
)
]
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
return result
}
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()]
}
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()]
}
}
}
+24 -24
View File
@@ -2,36 +2,36 @@ import Virtualization
@available(macOS 13, *)
struct Linux: Platform {
func os() -> OS {
.linux
}
func os() -> OS {
.linux
}
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
let result = VZEFIBootLoader()
result.variableStore = VZEFIVariableStore(url: nvramURL)
result.variableStore = VZEFIVariableStore(url: nvramURL)
return result
}
return result
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func platform(nvramURL: URL) -> VZPlatformConfiguration {
VZGenericPlatformConfiguration()
}
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
let result = VZVirtioGraphicsDeviceConfiguration()
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
result.scanouts = [
VZVirtioGraphicsScanoutConfiguration(
widthInPixels: vmConfig.display.width,
heightInPixels: vmConfig.display.height
)
]
return result
}
return result
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
import Virtualization
enum OS: String, Codable {
case darwin
case linux
case darwin
case linux
}
+5 -5
View File
@@ -1,9 +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]
func os() -> OS
func bootLoader(nvramURL: URL) throws -> VZBootLoader
func platform(nvramURL: URL) -> VZPlatformConfiguration
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
func pointingDevices() -> [VZPointingDeviceConfiguration]
}
+5 -3
View File
@@ -5,9 +5,9 @@ import Puppy
var puppy = Puppy.default
class LogFormatter: LogFormattable {
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
"\(date) \(level) \(message)"
}
func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, file: String, line: UInt, swiftLogInfo: [String: String], label: String, date: Date, threadID: UInt64) -> String {
"\(date) \(level) \(message)"
}
}
@main
@@ -20,6 +20,7 @@ struct Root: AsyncParsableCommand {
Clone.self,
Run.self,
Set.self,
Get.self,
List.self,
Login.self,
IP.self,
@@ -27,6 +28,7 @@ struct Root: AsyncParsableCommand {
Push.self,
Prune.self,
Rename.self,
Stop.self,
Delete.self,
])
+1 -1
View File
@@ -13,7 +13,7 @@ func resolveBinaryPath(_ name: String) -> URL? {
for pathComponent in path.split(separator: ":") {
let url = URL(fileURLWithPath: String(pathComponent))
.appendingPathComponent(name, isDirectory: false)
.appendingPathComponent(name, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
return url
+34 -38
View File
@@ -14,8 +14,8 @@ 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"
init(_ what: String, _ plural: String, _ requires: String = "running macOS 13.0 (Ventura) or newer") {
description = "error: \(what) \(plural) only supported on hosts \(requires)"
}
}
@@ -31,7 +31,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// VM's config
var name: String
// VM's config
var config: VMConfig
@@ -40,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
init(vmDir: VMDirectory,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directoryShares: [DirectoryShare] = []
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
) throws {
name = vmDir.name
config = try VMConfig.init(fromURL: vmDir.configURL)
@@ -52,9 +52,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directoryShares: directoryShares)
nvramURL: vmDir.nvramURL, vmConfig: config,
network: network, additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices
)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
@@ -63,9 +64,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
// Check if we already have this IPSW in cache
let (bytes, response) = try await URLSession.shared.bytes(from: remoteURL)
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
if let hash = (response as! HTTPURLResponse).value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
if FileManager.default.fileExists(atPath: ipswLocation.path) {
@@ -90,7 +91,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
let digest = Digest()
for try await chunk in bytes.chunks(ofCount: chunkSizeBytes) {
for try await chunk in channel {
let chunkAsData = Data(chunk)
fileHandle.write(chunkAsData)
digest.update(chunkAsData)
@@ -121,7 +122,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
virtualMachine.state == VZVirtualMachine.State.stopped ||
virtualMachine.state == VZVirtualMachine.State.paused ||
virtualMachine.state == VZVirtualMachine.State.error
}
}
@@ -130,7 +131,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL: URL,
diskSizeGB: UInt16,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
) async throws {
var ipswURL = ipswURL
@@ -138,6 +140,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
}
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
// cannot deal with paths that contain symlinks, so expand them here first.
ipswURL.resolveSymlinksInPath()
// Load the restore image and try to get the requirements
// that match both the image and our platform
let image = try await withCheckedThrowingContinuation { continuation in
@@ -170,9 +177,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Initialize the virtual machine and its configuration
self.network = network
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directoryShares: [])
vmConfig: config, network: network,
additionalDiskAttachments: additionalDiskAttachments,
directorySharingDevices: directorySharingDevices
)
virtualMachine = VZVirtualMachine(configuration: configuration)
super.init()
@@ -211,7 +219,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
func run(_ recovery: Bool) async throws {
try network.run(sema)
DispatchQueue.main.sync {
let startTask = DispatchQueue.main.sync {
Task {
if #available(macOS 13, *) {
// new API introduced in Ventura
@@ -225,7 +233,12 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}
await withTaskCancellationHandler(operation: {
try await withTaskCancellationHandler(operation: {
// Await on VZVirtualMachine.start() result
_ = try await startTask.value
// Wait for the VM to finish running
// or for the exit condition
sema.wait()
}, onCancel: {
sema.signal()
@@ -248,7 +261,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
vmConfig: VMConfig,
network: Network = NetworkShared(),
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
directoryShares: [DirectoryShare]
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
) throws -> VZVirtualMachineConfiguration {
let configuration = VZVirtualMachineConfiguration()
@@ -292,19 +305,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
// Entropy
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
// Directory share
if #available(macOS 13, *) {
var directories: [String : VZSharedDirectory] = Dictionary()
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
configuration.directorySharingDevices = [sharingDevice]
} else if !directoryShares.isEmpty {
throw UnsupportedOSError("directory sharing", "is")
}
// Directory sharing devices
configuration.directorySharingDevices = directorySharingDevices
try configuration.validate()
@@ -317,7 +319,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
print("guest has stopped the virtual machine due to error")
print("guest has stopped the virtual machine due to error: \(error)")
sema.signal()
}
@@ -326,9 +328,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
sema.signal()
}
}
struct DirectoryShare {
let name: String
let path: URL
let readOnly: Bool
}
+10 -10
View File
@@ -48,10 +48,10 @@ struct VMConfig: Codable {
var display: VMDisplayConfig = VMDisplayConfig()
init(
platform: Platform,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
platform: Platform,
cpuCountMin: Int,
memorySizeMin: UInt64,
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
) {
self.os = platform.os()
self.arch = CurrentArchitecture()
@@ -105,12 +105,12 @@ struct VMConfig: Codable {
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
throw DecodingError.dataCorruptedError(
forKey: .hardwareModel,
in: container,
debugDescription: "failed to initialize VZMacAddress using the provided value")
forKey: .hardwareModel,
in: container,
debugDescription: "failed to initialize VZMacAddress using the provided value")
}
self.macAddress = macAddress
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
}
@@ -132,7 +132,7 @@ struct VMConfig: Codable {
mutating func setCPU(cpuCount: Int) throws {
if cpuCount < cpuCountMin {
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
+ " at minimum (requested \(cpuCount))")
+ " at minimum (requested \(cpuCount))")
}
self.cpuCount = cpuCount
@@ -141,7 +141,7 @@ struct VMConfig: Codable {
mutating func setMemory(memorySize: UInt64) throws {
if memorySize < memorySizeMin {
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
+ " of memory at minimum (requested \(memorySizeMin))")
+ " of memory at minimum (requested \(memorySizeMin))")
}
self.memorySize = memorySize
+8 -8
View File
@@ -59,11 +59,11 @@ extension VMDirectory {
// Progress
let diskCompressedSize: Int64 = Int64(diskLayers.map {
$0.size
}
.reduce(0) {
$0 + $1
})
$0.size
}
.reduce(0) {
$0 + $1
})
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
let progress = Progress(totalUnitCount: diskCompressedSize)
@@ -144,9 +144,9 @@ extension VMDirectory {
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
let manifest = OCIManifest(
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
layers: layers,
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
)
// Manifest
+7 -8
View File
@@ -1,12 +1,6 @@
import Foundation
import Virtualization
struct UninitializedVMDirectoryError: Error {
}
struct AlreadyInitializedVMDirectoryError: Error {
}
struct VMDirectory: Prunable {
var baseURL: URL
@@ -47,7 +41,7 @@ struct VMDirectory: Prunable {
func initialize(overwrite: Bool = false) throws {
if !overwrite && initialized {
throw AlreadyInitializedVMDirectoryError()
throw RuntimeError("VM directory is already initialized, preventing overwrite")
}
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
@@ -58,8 +52,13 @@ struct VMDirectory: Prunable {
}
func validate() throws {
if !FileManager.default.fileExists(atPath: baseURL.path) {
throw RuntimeError("the specified VM does not exist")
}
if !initialized {
throw UninitializedVMDirectoryError()
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
}
}
+7 -7
View File
@@ -39,7 +39,7 @@ class VMStorageOCI: PrunableStorage {
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
// for github.com/org/repo:latest)
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
withIntermediateDirectories: true)
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
}
@@ -53,7 +53,7 @@ class VMStorageOCI: PrunableStorage {
var refCounts = Dictionary<URL, UInt>()
guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
return
}
@@ -90,7 +90,7 @@ class VMStorageOCI: PrunableStorage {
var result: [(String, VMDirectory, Bool)] = Array()
guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
return []
}
@@ -127,7 +127,7 @@ class VMStorageOCI: PrunableStorage {
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
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)
@@ -165,15 +165,15 @@ class VMStorageOCI: PrunableStorage {
if capacityImportant == 0 || capacityAvailable == 0 {
puppy.warning("important capacity \(capacityImportant) bytes, "
+ "available capacity is \(capacityAvailable) bytes")
+ "available capacity is \(capacityAvailable) bytes")
}
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
// Let's validate to avoid unnecessary pruning.
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
+ "is \(requiredCapacityBytes) bytes)")
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
+ "is \(requiredCapacityBytes) bytes)")
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
}
+26 -26
View File
@@ -3,36 +3,36 @@ import Dynamic
import Virtualization
class FullFledgedVNC: VNC {
let password: String
private let vnc: Dynamic
let password: String
private let vnc: Dynamic
init(virtualMachine: VZVirtualMachine) {
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
securityConfiguration: securityConfiguration)
vnc.virtualMachine = virtualMachine
vnc.start()
}
init(virtualMachine: VZVirtualMachine) {
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
securityConfiguration: securityConfiguration)
vnc.virtualMachine = virtualMachine
vnc.start()
}
func waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
func waitForURL() async throws -> URL {
while true {
// Port is 0 shortly after start(),
// but will be initialized later
if let port = vnc.port.asUInt16, port != 0 {
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
}
}
func stop() throws {
vnc.stop()
// Wait 50 ms.
try await Task.sleep(nanoseconds: 50_000_000)
}
}
deinit {
try? stop()
}
func stop() throws {
vnc.stop()
}
deinit {
try? stop()
}
}
+22 -22
View File
@@ -2,33 +2,33 @@ 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()
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()
}
// 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()
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()
// 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())
}
let secondLock = try! FileLock(lockURL: url)
XCTAssertFalse(try secondLock.trylock())
}
private func temporaryFile() -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
private func temporaryFile() -> URL {
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: url.path, contents: nil)
FileManager.default.createFile(atPath: url.path, contents: nil)
return url
}
return url
}
}
+17 -17
View File
@@ -5,31 +5,31 @@ import Network
final class MACAddressResolverTests: XCTestCase {
func testSingleEntry() throws {
let leases = try Leases("""
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
""")
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
""")
XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
}
func testMultipleEntries() throws {
let leases = try Leases("""
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
{
ip_address=5.6.7.8
hw_address=1,AA:BB:CC:DD:EE:FF
}
""")
{
ip_address=1.2.3.4
hw_address=1,00:11:22:33:44:55
}
{
ip_address=5.6.7.8
hw_address=1,AA:BB:CC:DD:EE:FF
}
""")
XCTAssertEqual(IPv4Address("1.2.3.4"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
XCTAssertEqual(IPv4Address("5.6.7.8"),
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
}
}
+72 -72
View File
@@ -2,88 +2,88 @@ import XCTest
@testable import tart
final class RegistryTests: XCTestCase {
var registryRunner: RegistryRunner?
var registryRunner: RegistryRunner?
override func setUp() async throws {
try await super.setUp()
override func setUp() async throws {
try await super.setUp()
do {
registryRunner = try await RegistryRunner()
} catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
}
do {
registryRunner = try await RegistryRunner()
} catch {
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
}
}
override func tearDown() async throws {
try await super.tearDown()
registryRunner = nil
}
var registry: Registry {
registryRunner!.registry
}
func testPushPullBlobSmall() async throws {
// Generate a simple blob
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
// Push it
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
// Pull it
var pulledBlob = Data()
try await registry.pullBlob(pushedBlobDigest) { data in
pulledBlob.append(data)
}
override func tearDown() async throws {
try await super.tearDown()
// Ensure that both blobs are identical
XCTAssertEqual(pushedBlob, pulledBlob)
}
registryRunner = nil
func testPushPullBlobHugeInChunks() async throws {
// Generate a large enough blob
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
// Push it
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
// Pull it
var pulledLargeBlob = Data()
try await registry.pullBlob(largeBlobDigest) { data in
pulledLargeBlob.append(data)
}
var registry: Registry {
registryRunner!.registry
}
// Ensure that both blobs are identical
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
}
func testPushPullBlobSmall() async throws {
// Generate a simple blob
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
func testPushPullManifest() async throws {
// Craft a basic config
let configData = try OCIConfig().toJSON()
let configDigest = try await registry.pushBlob(fromData: configData)
// Push it
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
// Craft a basic layer
let layerData = Data("doesn't matter".utf8)
let layerDigest = try await registry.pushBlob(fromData: layerData)
// Pull it
var pulledBlob = Data()
try await registry.pullBlob(pushedBlobDigest) { data in
pulledBlob.append(data)
}
// Craft a basic manifest and push it
let manifest = OCIManifest(
config: OCIManifestConfig(size: configData.count, digest: configDigest),
layers: [
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
]
)
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
// Ensure that both blobs are identical
XCTAssertEqual(pushedBlob, pulledBlob)
}
// Ensure that the manifest pulled by tag matches with the one pushed above
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
XCTAssertEqual(manifest, pulledByTagManifest)
func testPushPullBlobHugeInChunks() async throws {
// Generate a large enough blob
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)!
// Push it
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
// Pull it
var pulledLargeBlob = Data()
try await registry.pullBlob(largeBlobDigest) { data in
pulledLargeBlob.append(data)
}
// Ensure that both blobs are identical
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
}
func testPushPullManifest() async throws {
// Craft a basic config
let configData = try OCIConfig().toJSON()
let configDigest = try await registry.pushBlob(fromData: configData)
// Craft a basic layer
let layerData = Data("doesn't matter".utf8)
let layerDigest = try await registry.pushBlob(fromData: layerData)
// Craft a basic manifest and push it
let manifest = OCIManifest(
config: OCIManifestConfig(size: configData.count, digest: configDigest),
layers: [
OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest)
]
)
let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest)
// Ensure that the manifest pulled by tag matches with the one pushed above
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
XCTAssertEqual(manifest, pulledByTagManifest)
// Ensure that the manifest pulled by digest matches with the one pushed above
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
XCTAssertEqual(manifest, pulledByDigestManifest)
}
// Ensure that the manifest pulled by digest matches with the one pushed above
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
XCTAssertEqual(manifest, pulledByDigestManifest)
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ final class RemoteNameTests: XCTestCase {
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
}
func testComplexTag() throws {
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
@@ -22,7 +22,7 @@ final class RemoteNameTests: XCTestCase {
)
XCTAssertEqual(expectedRemoteName,
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
}
func testASCIIOnly() throws {
+2 -2
View File
@@ -4,14 +4,14 @@ import XCTest
final class URLAbsolutizationTets: XCTestCase {
func testNeedsAbsolutization() throws {
let url = URL(string: "/v2/some/path?some=query")!
.absolutize(URL(string: "https://example.com/v2/")!)
.absolutize(URL(string: "https://example.com/v2/")!)
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
}
func testDoesntNeedAbsolutization() throws {
let url = URL(string: "https://example.org/v2/some/path?some=query")!
.absolutize(URL(string: "https://example.com/v2/")!)
.absolutize(URL(string: "https://example.com/v2/")!)
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
}
+34 -34
View File
@@ -2,53 +2,53 @@ import Foundation
@testable import tart
enum RegistryRunnerError: Error {
case DockerFailed(exitCode: Int32)
case DockerFailed(exitCode: Int32)
}
class RegistryRunner {
let containerID: String
let registry: Registry
let containerID: String
let registry: Registry
static func dockerCmd(_ arguments: String...) throws -> String {
let stdoutPipe = Pipe()
static func dockerCmd(_ arguments: String...) throws -> String {
let stdoutPipe = Pipe()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
proc.arguments = arguments
proc.standardOutput = stdoutPipe
try proc.run()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
proc.arguments = arguments
proc.standardOutput = stdoutPipe
try proc.run()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
proc.waitUntilExit()
proc.waitUntilExit()
if proc.terminationStatus != 0 {
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
}
return String(data: stdoutData, encoding: .utf8) ?? ""
if proc.terminationStatus != 0 {
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
}
init() async throws {
// Start container
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container
return String(data: stdoutData, encoding: .utf8) ?? ""
}
// Get forwarded port
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
.trimmingCharacters(in: CharacterSet.newlines)
init() async throws {
// Start container
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
.trimmingCharacters(in: CharacterSet.newlines)
containerID = container
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
namespace: "vm-image")
// Get forwarded port
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
.trimmingCharacters(in: CharacterSet.newlines)
// Wait for the Docker Registry to start
while ((try? await registry.ping()) == nil) {
try await Task.sleep(nanoseconds: 100_000_000)
}
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
namespace: "vm-image")
// Wait for the Docker Registry to start
while ((try? await registry.ping()) == nil) {
try await Task.sleep(nanoseconds: 100_000_000)
}
}
deinit {
_ = try! Self.dockerCmd("kill", containerID)
}
deinit {
_ = try! Self.dockerCmd("kill", containerID)
}
}
+16
View File
@@ -0,0 +1,16 @@
import pytest
from tart import Tart
from docker_registry import DockerRegistry
@pytest.fixture(scope="class")
def tart():
with Tart() as tart:
yield tart
@pytest.fixture(scope="class")
def docker_registry():
with DockerRegistry() as docker_registry:
yield docker_registry
+20
View File
@@ -0,0 +1,20 @@
import requests
from testcontainers.core.waiting_utils import wait_container_is_ready
from testcontainers.general import DockerContainer
class DockerRegistry(DockerContainer):
_default_exposed_port = 5000
def __init__(self):
super().__init__("registry:2")
self.with_exposed_ports(self._default_exposed_port)
@wait_container_is_ready(requests.exceptions.ConnectionError)
def remote_name(self, for_vm: str):
exposed_port = self.get_exposed_port(self._default_exposed_port)
requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
+5
View File
@@ -0,0 +1,5 @@
pytest
testcontainers
requests
bitmath
pytest-dependency
+33
View File
@@ -0,0 +1,33 @@
import tempfile
import os
import subprocess
class Tart:
def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
os.symlink(src, dst)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.tmp_dir.cleanup()
def home(self) -> str:
return self.tmp_dir.name
def run(self, args):
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
+10
View File
@@ -0,0 +1,10 @@
def test_clone(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Clone the VM
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\nubuntu\n"
+16
View File
@@ -0,0 +1,16 @@
def test_create_macos(tart):
# Create a macOS VM
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "macos-vm\n"
def test_create_linux(tart):
# Create a Linux VM
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "linux-vm\n"
+14
View File
@@ -0,0 +1,14 @@
def test_delete(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == ""
+55
View File
@@ -0,0 +1,55 @@
import os
import tempfile
import timeit
import uuid
import bitmath
import pytest
amount_to_transfer = bitmath.GB(1)
minimal_speed_per_second = bitmath.Mb(100)
class TestOCI:
@pytest.mark.dependency()
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@staticmethod
def _calculate_speed_per_second(amount_transferred, time_taken):
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
@pytest.fixture(scope="class")
def vm_with_random_disk(tart):
vm_name = str(uuid.uuid4())
# Create a VM (Linux for speed's sake)
tart.run(["create", "--linux", vm_name])
# Populate VM's disk with "amount_to_transfer" of random bytes
# to effectively disable Tart's OCI blob compression
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tf.write(os.urandom(amount_to_transfer.bytes))
tf.close()
os.rename(tf.name, disk_path)
yield vm_name
tart.run(["delete", vm_name])
+10
View File
@@ -0,0 +1,10 @@
def test_rename(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Rename that VM
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "ubuntu\n"