mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-22 03:24:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31635e59d7 | ||
|
|
e061d00afc | ||
|
|
4555dd5824 | ||
|
|
ad9c3c661e | ||
|
|
c27d4a089c | ||
|
|
70f9fcc12e | ||
|
|
456ebc1c7b | ||
|
|
14cbc727ad | ||
|
|
03e6d9345d | ||
|
|
c09cbefdd0 | ||
|
|
3896728eb9 | ||
|
|
de993fbf6d | ||
|
|
f08ddf3855 | ||
|
|
8524d93741 | ||
|
|
833c162187 | ||
|
|
31ba71dad7 | ||
|
|
5e77968989 | ||
|
|
f37372da28 | ||
|
|
e600d2f036 | ||
|
|
b21dbbe3a3 | ||
|
|
ee0fbdd83d | ||
|
|
8961c5189a | ||
|
|
555715588d | ||
|
|
8d096966b4 |
+38
-3
@@ -1,13 +1,44 @@
|
|||||||
|
use_compute_credits: true
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Ventura
|
||||||
|
alias: test
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: Mac-Mini-M1
|
name: scaleway-m1
|
||||||
build_script: swift test
|
test_script:
|
||||||
test_script: swift test
|
- 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:
|
task:
|
||||||
name: Build
|
name: Build
|
||||||
|
alias: build
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
@@ -19,6 +50,10 @@ task:
|
|||||||
task:
|
task:
|
||||||
name: Release
|
name: Release
|
||||||
only_if: $CIRRUS_TAG != ''
|
only_if: $CIRRUS_TAG != ''
|
||||||
|
depends_on:
|
||||||
|
- lint
|
||||||
|
- test
|
||||||
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* @edigaryev @fkorotkov
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
--disable all
|
||||||
|
--enable indent
|
||||||
|
--indent 2
|
||||||
|
--exclude Sources/tart/OCI/Reference/Generated
|
||||||
|
--swiftversion 5.7
|
||||||
@@ -98,6 +98,14 @@
|
|||||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||||
"version" : "6.3.1"
|
"version" : "6.3.1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftformat",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/edigaryev/SwiftFormat",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "46f34946546a29eeb6cb23ec44936b74fd379d63"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 2
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/sushichop/Puppy", from: "0.5.1"),
|
.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/antlr/antlr4", branch: "dev"),
|
||||||
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
.package(url: "https://github.com/apple/swift-atomics.git", .upToNextMajor(from: "1.0.0")),
|
||||||
|
.package(url: "https://github.com/edigaryev/SwiftFormat", branch: "46f34946546a29eeb6cb23ec44936b74fd379d63"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||

|
<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.
|
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||||
@@ -22,6 +22,22 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</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
|
## Usage
|
||||||
|
|
||||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||||
@@ -180,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.
|
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:
|
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||||
|
|
||||||
```json
|
```hcl
|
||||||
{
|
packer {
|
||||||
"builders": [
|
required_plugins {
|
||||||
{
|
tart = {
|
||||||
"name": "tart",
|
version = ">= 0.5.3"
|
||||||
"type": "tart-cli",
|
source = "github.com/cirruslabs/tart"
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
],
|
}
|
||||||
"provisioners": [
|
}
|
||||||
{
|
|
||||||
"inline": [
|
source "tart-cli" "tart" {
|
||||||
"echo 'Disabling spotlight indexing...'",
|
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
|
||||||
"sudo mdutil -a -i off"
|
vm_name = "my-custom-ventura"
|
||||||
],
|
cpu_count = 4
|
||||||
"type": "shell"
|
memory_gb = 8
|
||||||
},
|
disk_size_gb = 70
|
||||||
# more provisioners
|
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
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -225,6 +244,12 @@ tart login acme.io
|
|||||||
|
|
||||||
Credentials are securely stored in Keychain.
|
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
|
#### Pushing a Local Image
|
||||||
|
|
||||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||||
@@ -249,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.
|
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
|
## FAQ
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4d1f58f6460217434ae133c6d6e444320eb705c5dcf9efebbb215b117ae452c1
|
||||||
|
size 589
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:630c4975b9eead9e4f62e2a5375509dc0ee6cab2940ec3ffc2ae16b08d325964
|
||||||
|
size 6765
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5e48fbe90f6cc84e100fc286a5349c681cacb22358811181046d35ea298309de
|
||||||
|
size 16379
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1c7f7f9bf0fc0c9807f3a415fe6bf773c8a416b351c5aa08de46149a13de7387
|
||||||
|
size 5297
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ struct Login: AsyncParsableCommand {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||||
credentialsProviders: [credentialsProvider])
|
credentialsProviders: [credentialsProvider])
|
||||||
try await registry.ping()
|
try await registry.ping()
|
||||||
} catch {
|
} catch {
|
||||||
print("invalid credentials: \(error)")
|
print("invalid credentials: \(error)")
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ struct Prune: AsyncParsableCommand {
|
|||||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
@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.",
|
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var olderThan: UInt?
|
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",
|
@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.",
|
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||||
valueName: "n"))
|
valueName: "n"))
|
||||||
var cacheBudget: UInt?
|
var cacheBudget: UInt?
|
||||||
|
|
||||||
@Flag(help: .hidden)
|
@Flag(help: .hidden)
|
||||||
@@ -62,8 +62,8 @@ struct Prune: AsyncParsableCommand {
|
|||||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() > $1.accessDate() }
|
.sorted { try $0.accessDate() > $1.accessDate() }
|
||||||
|
|
||||||
var cacheBudgetBytes = cacheBudgetBytes
|
var cacheBudgetBytes = cacheBudgetBytes
|
||||||
var prunablesToDelete: [Prunable] = []
|
var prunablesToDelete: [Prunable] = []
|
||||||
@@ -87,8 +87,8 @@ struct Prune: AsyncParsableCommand {
|
|||||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||||
let prunables: [Prunable] = try prunableStorages
|
let prunables: [Prunable] = try prunableStorages
|
||||||
.flatMap { try $0.prunables() }
|
.flatMap { try $0.prunables() }
|
||||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||||
|
|
||||||
// Does it even make sense to start?
|
// Does it even make sense to start?
|
||||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ struct Push: AsyncParsableCommand {
|
|||||||
var insecure: Bool = false
|
var insecure: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||||
discussion: """
|
discussion: """
|
||||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
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.
|
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.
|
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
|
var chunkSize: Int = 0
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
@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
|
var populateCache: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -49,7 +49,7 @@ struct Push: AsyncParsableCommand {
|
|||||||
// Push VM
|
// Push VM
|
||||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace,
|
||||||
insecure: insecure)
|
insecure: insecure)
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||||
|
|||||||
@@ -2,39 +2,39 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct Rename: AsyncParsableCommand {
|
struct Rename: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
static var configuration = CommandConfiguration(abstract: "Rename a VM")
|
||||||
|
|
||||||
@Argument(help: "VM name")
|
@Argument(help: "VM name")
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
var newName: String
|
var newName: String
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
|
|
||||||
if !localStorage.exists(name) {
|
if !localStorage.exists(name) {
|
||||||
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
throw ValidationError("failed to rename a non-existent VM: \(name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if localStorage.exists(newName) {
|
if localStorage.exists(newName) {
|
||||||
throw ValidationError("failed to rename VM \(name), target VM \(name) already exists, delete it first!")
|
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)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
print(error)
|
print(error)
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-44
@@ -15,8 +15,8 @@ struct Run: AsyncParsableCommand {
|
|||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Don't open a UI window.",
|
"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."))
|
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
|
var noGraphics: Bool = false
|
||||||
|
|
||||||
@Flag(help: "Force open a UI window, even when VNC is enabled.")
|
@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")
|
@Flag(help: "Boot into recovery mode")
|
||||||
var recovery: Bool = false
|
var recovery: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
"Use screen sharing instead of the built-in UI.",
|
"Use screen sharing instead of the built-in UI.",
|
||||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||||
var vnc: Bool = false
|
var vnc: Bool = false
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp(
|
@Flag(help: ArgumentHelp(
|
||||||
@@ -41,32 +41,46 @@ struct Run: AsyncParsableCommand {
|
|||||||
var withSoftnet: Bool = false
|
var withSoftnet: Bool = false
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
Learn how to create a disk image using Disk Utility here:
|
Learn how to create a disk image using Disk Utility here:
|
||||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||||
""", valueName: "path[:ro]"))
|
""", valueName: "path[:ro]"))
|
||||||
var disk: [String] = []
|
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("""
|
@Option(help: ArgumentHelp("""
|
||||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
Requires host to be macOS 13.0 (Ventura) or newer.
|
Requires host to be macOS 13.0 (Ventura) or newer.
|
||||||
All shared directories are automatically mounted to "/Volumes/My Shared Files" directory on macOS,
|
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".
|
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.
|
For macOS guests, they must be running macOS 13.0 (Ventura) or newer.
|
||||||
""", valueName: "name:path[:ro]"))
|
""", valueName: "name:path[:ro]"))
|
||||||
var dir: [String] = []
|
var dir: [String] = []
|
||||||
|
|
||||||
@Option(help: ArgumentHelp("""
|
@Option(help: ArgumentHelp("""
|
||||||
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
Use bridged networking instead of the default shared (NAT) networking \n(e.g. --net-bridged=en0 or --net-bridged=\"Wi-Fi\")
|
||||||
""", discussion: """
|
""", discussion: """
|
||||||
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
|
||||||
""", valueName: "interface name"))
|
""", valueName: "interface name"))
|
||||||
var netBridged: String?
|
var netBridged: String?
|
||||||
|
|
||||||
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
|
@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
|
var netSoftnet: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
@@ -90,11 +104,29 @@ struct Run: AsyncParsableCommand {
|
|||||||
@MainActor
|
@MainActor
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
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(
|
vm = try VM(
|
||||||
vmDir: vmDir,
|
vmDir: vmDir,
|
||||||
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
network: userSpecifiedNetwork(vmDir: vmDir) ?? NetworkShared(),
|
||||||
additionalDiskAttachments: additionalDiskAttachments(),
|
additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares()
|
directorySharingDevices: directoryShares() + rosettaDirectoryShare()
|
||||||
)
|
)
|
||||||
|
|
||||||
let vncImpl: VNC? = try {
|
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 {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
@@ -129,11 +179,6 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
|
||||||
print("Virtual machine \"\(name)\" is already running!")
|
|
||||||
Foundation.exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
print(error)
|
print(error)
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
@@ -216,8 +261,22 @@ struct Run: AsyncParsableCommand {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func directoryShares() throws -> [DirectoryShare] {
|
func directoryShares() throws -> [VZDirectorySharingDeviceConfiguration] {
|
||||||
var result: [DirectoryShare] = []
|
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 {
|
for rawDir in dir {
|
||||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||||
@@ -238,10 +297,46 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
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() {
|
private func runUI() {
|
||||||
@@ -262,16 +357,16 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||||
}.commands {
|
}.commands {
|
||||||
// Remove some standard menu options
|
// Remove some standard menu options
|
||||||
CommandGroup(replacing: .help, addition: {})
|
CommandGroup(replacing: .help, addition: {})
|
||||||
CommandGroup(replacing: .newItem, addition: {})
|
CommandGroup(replacing: .newItem, addition: {})
|
||||||
CommandGroup(replacing: .pasteboard, addition: {})
|
CommandGroup(replacing: .pasteboard, addition: {})
|
||||||
CommandGroup(replacing: .textEditing, addition: {})
|
CommandGroup(replacing: .textEditing, addition: {})
|
||||||
CommandGroup(replacing: .undoRedo, addition: {})
|
CommandGroup(replacing: .undoRedo, addition: {})
|
||||||
CommandGroup(replacing: .windowSize, addition: {})
|
CommandGroup(replacing: .windowSize, addition: {})
|
||||||
// Replace some standard menu options
|
// Replace some standard menu options
|
||||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
var cpu: UInt16?
|
var cpu: UInt16?
|
||||||
|
|
||||||
@Option(help: "VM memory size in megabytes")
|
@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")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let memory = memory {
|
if let memory = memory {
|
||||||
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
|
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let display = display {
|
if let display = display {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ struct Config {
|
|||||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||||
} else {
|
} else {
|
||||||
tartHomeDir = FileManager.default
|
tartHomeDir = FileManager.default
|
||||||
.homeDirectoryForCurrentUser
|
.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".tart", isDirectory: true)
|
.appendingPathComponent(".tart", isDirectory: true)
|
||||||
}
|
}
|
||||||
self.tartHomeDir = tartHomeDir
|
self.tartHomeDir = tartHomeDir
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ struct Config {
|
|||||||
|
|
||||||
func gc() throws {
|
func gc() throws {
|
||||||
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
for entry in try FileManager.default.contentsOfDirectory(at: tartTmpDir,
|
||||||
includingPropertiesForKeys: [], options: []) {
|
includingPropertiesForKeys: [], options: []) {
|
||||||
let lock = try FileLock(lockURL: entry)
|
let lock = try FileLock(lockURL: entry)
|
||||||
if try !lock.trylock() {
|
if try !lock.trylock() {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum CredentialsProviderError: Error {
|
enum CredentialsProviderError: Error {
|
||||||
case Failed(message: String)
|
case Failed(message: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol CredentialsProvider {
|
protocol CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)?
|
func retrieve(host: String) throws -> (String, String)?
|
||||||
func store(host: String, user: String, password: String) throws
|
func store(host: String, user: String, password: String) throws
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,14 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||||
}
|
}
|
||||||
|
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = executableURL
|
process.executableURL = executableURL
|
||||||
process.arguments = ["get"]
|
process.arguments = ["get"]
|
||||||
|
|
||||||
let outPipe = Pipe()
|
let outPipe = Pipe()
|
||||||
let inPipe = Pipe()
|
let inPipe = Pipe()
|
||||||
|
|
||||||
process.standardOutput = outPipe
|
process.standardOutput = outPipe
|
||||||
process.standardError = outPipe
|
process.standardError = outPipe
|
||||||
process.standardInput = inPipe
|
process.standardInput = inPipe
|
||||||
@@ -38,7 +38,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
|
|||||||
|
|
||||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||||
inPipe.fileHandleForWriting.closeFile()
|
inPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
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
|
import Foundation
|
||||||
|
|
||||||
class KeychainCredentialsProvider: CredentialsProvider {
|
class KeychainCredentialsProvider: CredentialsProvider {
|
||||||
func retrieve(host: String) throws -> (String, String)? {
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||||
kSecAttrServer as String: host,
|
kSecAttrServer as String: host,
|
||||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
kSecReturnAttributes as String: true,
|
kSecReturnAttributes as String: true,
|
||||||
kSecReturnData as String: true,
|
kSecReturnData as String: true,
|
||||||
kSecAttrLabel as String: "Tart Credentials",
|
kSecAttrLabel as String: "Tart Credentials",
|
||||||
]
|
]
|
||||||
|
|
||||||
var item: CFTypeRef?
|
var item: CFTypeRef?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||||
|
|
||||||
if status != errSecSuccess {
|
if status != errSecSuccess {
|
||||||
if status == errSecItemNotFound {
|
if status == errSecItemNotFound {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
throw CredentialsProviderError.Failed(message: "Keychain returned unsuccessful status \(status)")
|
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func store(host: String, user: String, password: String) throws {
|
guard let item = item as? [String: Any],
|
||||||
let passwordData = password.data(using: .utf8)
|
let user = item[kSecAttrAccount as String] as? String,
|
||||||
let key: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
let passwordData = item[kSecValueData as String] as? Data,
|
||||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
let password = String(data: passwordData, encoding: .utf8)
|
||||||
kSecAttrServer as String: host,
|
else {
|
||||||
kSecAttrLabel as String: "Tart Credentials",
|
throw CredentialsProviderError.Failed(message: "Keychain item has unexpected format")
|
||||||
]
|
|
||||||
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())")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
extension OSStatus {
|
||||||
|
|||||||
+51
-30
@@ -1,46 +1,67 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
|
|
||||||
class Fetcher: NSObject, URLSessionTaskDelegate, URLSessionDelegate, URLSessionDataDelegate {
|
fileprivate let urlSession = createURLSession()
|
||||||
let responseCh = AsyncThrowingChannel<URLResponse, Error>()
|
|
||||||
|
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 dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
func fetch(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, URLResponse) {
|
let (data, response) = try await urlSession.data(for: request)
|
||||||
let task = URLSession.shared.dataTask(with: request)
|
|
||||||
task.delegate = self
|
|
||||||
task.resume()
|
|
||||||
|
|
||||||
// Wait for the response and only then return
|
Task {
|
||||||
var iter = responseCh.makeAsyncIterator()
|
await dataCh.send(data)
|
||||||
let response = try await iter.next()!
|
|
||||||
|
|
||||||
return (dataCh, response)
|
dataCh.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition {
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
await responseCh.send(response)
|
}
|
||||||
|
|
||||||
return .allow
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
let sema = DispatchSemaphore(value: 0)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
fileprivate func createURLSession() -> URLSession {
|
||||||
await dataCh.send(data)
|
let config = URLSessionConfiguration.default
|
||||||
sema.signal()
|
|
||||||
}
|
|
||||||
|
|
||||||
sema.wait()
|
// 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
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
return URLSession(configuration: config)
|
||||||
if let error = error {
|
|
||||||
// Premature termination
|
|
||||||
responseCh.fail(error)
|
|
||||||
dataCh.fail(error)
|
|
||||||
} else {
|
|
||||||
dataCh.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-37
@@ -2,47 +2,47 @@ import Foundation
|
|||||||
import System
|
import System
|
||||||
|
|
||||||
enum FileLockError: Error, Equatable {
|
enum FileLockError: Error, Equatable {
|
||||||
case Failed(_ message: String)
|
case Failed(_ message: String)
|
||||||
case AlreadyLocked
|
case AlreadyLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileLock {
|
class FileLock {
|
||||||
let url: URL
|
let url: URL
|
||||||
let fd: Int32
|
let fd: Int32
|
||||||
|
|
||||||
init(lockURL: URL) throws {
|
init(lockURL: URL) throws {
|
||||||
url = lockURL
|
url = lockURL
|
||||||
fd = open(lockURL.path, 0)
|
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 {
|
return true
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ class IPSWCache: PrunableStorage {
|
|||||||
|
|
||||||
func prunables() throws -> [Prunable] {
|
func prunables() throws -> [Prunable] {
|
||||||
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,117 +3,117 @@ import Network
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||||
var terminationReason: Process.TerminationReason
|
var terminationReason: Process.TerminationReason
|
||||||
var terminationStatus: Int32
|
var terminationStatus: Int32
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
var reason: String
|
var reason: String
|
||||||
|
|
||||||
switch terminationReason {
|
switch terminationReason {
|
||||||
case .exit:
|
case .exit:
|
||||||
reason = "exit code \(terminationStatus)"
|
reason = "exit code \(terminationStatus)"
|
||||||
case .uncaughtSignal:
|
case .uncaughtSignal:
|
||||||
reason = "uncaught signal"
|
reason = "uncaught signal"
|
||||||
default:
|
default:
|
||||||
reason = "unknown reason"
|
reason = "unknown reason"
|
||||||
}
|
|
||||||
|
|
||||||
return "arp command failed: \(reason)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return "arp command failed: \(reason)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||||
var explanation: String
|
var explanation: String
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"arp command yielded invalid output: \(explanation)"
|
"arp command yielded invalid output: \(explanation)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||||
var explanation: String
|
var explanation: String
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
"ARPCache internal error: \(explanation)"
|
"ARPCache internal error: \(explanation)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ARPCache {
|
struct ARPCache {
|
||||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||||
let process = Process.init()
|
let process = Process.init()
|
||||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||||
process.arguments = ["-an"]
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
process.standardOutput = pipe
|
process.standardOutput = pipe
|
||||||
process.standardError = pipe
|
process.standardError = pipe
|
||||||
process.standardInput = FileHandle.nullDevice
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
try process.run()
|
try process.run()
|
||||||
process.waitUntilExit()
|
process.waitUntilExit()
|
||||||
|
|
||||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
throw ARPCommandFailedError(
|
throw ARPCommandFailedError(
|
||||||
terminationReason: process.terminationReason,
|
terminationReason: process.terminationReason,
|
||||||
terminationStatus: process.terminationStatus)
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
extension NSTextCheckingResult {
|
||||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||||
let nsRange = self.range(withName: name)
|
let nsRange = self.range(withName: name)
|
||||||
|
|
||||||
if nsRange.location == NSNotFound {
|
if nsRange.location == NSNotFound {
|
||||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
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])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let range = Range.init(nsRange, in: string) else {
|
||||||
|
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(string[range])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkBridged: Network {
|
class NetworkBridged: Network {
|
||||||
let interface: VZBridgedNetworkInterface
|
let interface: VZBridgedNetworkInterface
|
||||||
|
|
||||||
init(interface: VZBridgedNetworkInterface) {
|
init(interface: VZBridgedNetworkInterface) {
|
||||||
self.interface = interface
|
self.interface = interface
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachment() -> VZNetworkDeviceAttachment {
|
||||||
VZBridgedNetworkDeviceAttachment(interface: interface)
|
VZBridgedNetworkDeviceAttachment(interface: interface)
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() async throws {
|
func stop() async throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import Foundation
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class NetworkShared: Network {
|
class NetworkShared: Network {
|
||||||
func attachment() -> VZNetworkDeviceAttachment {
|
func attachment() -> VZNetworkDeviceAttachment {
|
||||||
VZNATNetworkDeviceAttachment()
|
VZNATNetworkDeviceAttachment()
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(_ sema: DispatchSemaphore) throws {
|
func run(_ sema: DispatchSemaphore) throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() async throws {
|
func stop() async throws {
|
||||||
// no-op, only used for Softnet
|
// no-op, only used for Softnet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ class Digest {
|
|||||||
extension SHA256.Digest {
|
extension SHA256.Digest {
|
||||||
func hexdigest() -> String {
|
func hexdigest() -> String {
|
||||||
"sha256:" + self.map {
|
"sha256:" + self.map {
|
||||||
String(format: "%02x", $0)
|
String(format: "%02x", $0)
|
||||||
}
|
}
|
||||||
.joined()
|
.joined()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class Registry {
|
|||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(urlComponents: URLComponents,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
baseURL = urlComponents.url!
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
@@ -111,7 +111,7 @@ class Registry {
|
|||||||
host: String,
|
host: String,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
insecure: Bool = false,
|
insecure: Bool = false,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
@@ -130,11 +130,11 @@ class Registry {
|
|||||||
let manifestJSON = try manifest.toJSON()
|
let manifestJSON = try manifest.toJSON()
|
||||||
|
|
||||||
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
let (data, response) = try await dataRequest(.PUT, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Content-Type": manifest.mediaType],
|
headers: ["Content-Type": manifest.mediaType],
|
||||||
body: manifestJSON)
|
body: manifestJSON)
|
||||||
if response.statusCode != HTTPCode.Created.rawValue {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||||
details: data.asText())
|
details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
return Digest.hash(manifestJSON)
|
return Digest.hash(manifestJSON)
|
||||||
@@ -142,10 +142,10 @@ class Registry {
|
|||||||
|
|
||||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||||
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
let (data, response) = try await dataRequest(.GET, endpointURL("\(namespace)/manifests/\(reference)"),
|
||||||
headers: ["Accept": ociManifestMediaType])
|
headers: ["Accept": ociManifestMediaType])
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||||
details: data.asText())
|
details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest = try OCIManifest(fromJSON: data)
|
let manifest = try OCIManifest(fromJSON: data)
|
||||||
@@ -168,17 +168,17 @@ class Registry {
|
|||||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||||
// Initiate a blob upload
|
// Initiate a blob upload
|
||||||
let (data, postResponse) = try await dataRequest(.POST, endpointURL("\(namespace)/blobs/uploads/"),
|
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 {
|
if postResponse.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||||
details: data.asText())
|
details: data.asText())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Figure out where to upload the blob
|
// Figure out where to upload the blob
|
||||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||||
|
|
||||||
let digest = Digest.hash(fromData)
|
let digest = Digest.hash(fromData)
|
||||||
|
|
||||||
if chunkSizeMb == 0 {
|
if chunkSizeMb == 0 {
|
||||||
// monolithic upload
|
// monolithic upload
|
||||||
let (data, response) = try await dataRequest(
|
let (data, response) = try await dataRequest(
|
||||||
@@ -192,7 +192,7 @@ class Registry {
|
|||||||
)
|
)
|
||||||
if response.statusCode != HTTPCode.Created.rawValue {
|
if response.statusCode != HTTPCode.Created.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||||
code: response.statusCode, details: data.asText())
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
@@ -215,22 +215,22 @@ class Registry {
|
|||||||
// always accept both statuses since AWS ECR is not following specification
|
// always accept both statuses since AWS ECR is not following specification
|
||||||
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||||
code: response.statusCode, details: data.asText())
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
uploadedBytes += chunk.count
|
uploadedBytes += chunk.count
|
||||||
// Update location for the next chunk
|
// Update location for the next chunk
|
||||||
uploadLocation = try uploadLocationFromResponse(response)
|
uploadLocation = try uploadLocationFromResponse(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
return digest
|
return digest
|
||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
||||||
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"))
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await channel.asData().asText()
|
let body = try await channel.asData().asText()
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||||
details: body)
|
details: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
for try await part in channel {
|
for try await part in channel {
|
||||||
@@ -247,15 +247,15 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func dataRequest(
|
private func dataRequest(
|
||||||
_ method: HTTPMethod,
|
_ method: HTTPMethod,
|
||||||
_ urlComponents: URLComponents,
|
_ urlComponents: URLComponents,
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true
|
||||||
) async throws -> (Data, HTTPURLResponse) {
|
) async throws -> (Data, HTTPURLResponse) {
|
||||||
let (channel, response) = try await channelRequest(method, urlComponents,
|
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||||
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||||
|
|
||||||
return (try await channel.asData(), response)
|
return (try await channel.asData(), response)
|
||||||
}
|
}
|
||||||
@@ -266,7 +266,8 @@ class Registry {
|
|||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true,
|
||||||
|
viaFile: Bool = false
|
||||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
@@ -292,12 +293,12 @@ class Registry {
|
|||||||
currentAuthToken = nil
|
currentAuthToken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (channel, response) = try await authAwareRequest(request: request)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
_ = try await channel.asData()
|
_ = try await channel.asData()
|
||||||
try await auth(response: response)
|
try await auth(response: response)
|
||||||
(channel, response) = try await authAwareRequest(request: request)
|
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (channel, response)
|
return (channel, response)
|
||||||
@@ -311,7 +312,7 @@ class Registry {
|
|||||||
|
|
||||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||||
|
|
||||||
if wwwAuthenticate.scheme == "Basic" {
|
if wwwAuthenticate.scheme.lowercased() == "basic" {
|
||||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||||
}
|
}
|
||||||
@@ -319,7 +320,7 @@ class Registry {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if wwwAuthenticate.scheme != "Bearer" {
|
if wwwAuthenticate.scheme.lowercased() != "bearer" {
|
||||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||||
}
|
}
|
||||||
@@ -372,7 +373,7 @@ class Registry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let token = currentAuthToken {
|
||||||
@@ -380,8 +381,6 @@ class Registry {
|
|||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (channel, response) = try await Fetcher().fetch(request)
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
|
|
||||||
return (channel, response as! HTTPURLResponse)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
import Foundation
|
||||||
|
|
||||||
struct PassphraseGenerator: Sequence {
|
struct PassphraseGenerator: Sequence {
|
||||||
func makeIterator() -> PassphraseIterator {
|
func makeIterator() -> PassphraseIterator {
|
||||||
PassphraseIterator()
|
PassphraseIterator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PassphraseIterator: IteratorProtocol {
|
struct PassphraseIterator: IteratorProtocol {
|
||||||
mutating func next() -> String? {
|
mutating func next() -> String? {
|
||||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum Architecture: String, Codable {
|
enum Architecture: String, Codable {
|
||||||
case arm64
|
case arm64
|
||||||
case amd64
|
case amd64
|
||||||
}
|
}
|
||||||
|
|
||||||
func CurrentArchitecture() -> Architecture {
|
func CurrentArchitecture() -> Architecture {
|
||||||
#if arch(arm64)
|
#if arch(arm64)
|
||||||
return .arm64
|
return .arm64
|
||||||
#elseif arch(x86_64)
|
#elseif arch(x86_64)
|
||||||
return .amd64
|
return .amd64
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,97 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct Darwin: Platform {
|
struct Darwin: Platform {
|
||||||
var ecid: VZMacMachineIdentifier
|
var ecid: VZMacMachineIdentifier
|
||||||
var hardwareModel: VZMacHardwareModel
|
var hardwareModel: VZMacHardwareModel
|
||||||
|
|
||||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||||
self.ecid = ecid
|
self.ecid = ecid
|
||||||
self.hardwareModel = hardwareModel
|
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 {
|
result.displays = [
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
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)
|
return result
|
||||||
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)
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
if #available(macOS 13, *) {
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
// Trackpad is only supported starting with macOS Ventura
|
||||||
}
|
// macOS Monterey will continue using a USB device == .darwin
|
||||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
} else {
|
||||||
}
|
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
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()]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,36 @@ import Virtualization
|
|||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
struct Linux: Platform {
|
struct Linux: Platform {
|
||||||
func os() -> OS {
|
func os() -> OS {
|
||||||
.linux
|
.linux
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||||
let result = VZEFIBootLoader()
|
let result = VZEFIBootLoader()
|
||||||
|
|
||||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||||
VZGenericPlatformConfiguration()
|
VZGenericPlatformConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||||
|
|
||||||
result.scanouts = [
|
result.scanouts = [
|
||||||
VZVirtioGraphicsScanoutConfiguration(
|
VZVirtioGraphicsScanoutConfiguration(
|
||||||
widthInPixels: vmConfig.display.width,
|
widthInPixels: vmConfig.display.width,
|
||||||
heightInPixels: vmConfig.display.height
|
heightInPixels: vmConfig.display.height
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
enum OS: String, Codable {
|
enum OS: String, Codable {
|
||||||
case darwin
|
case darwin
|
||||||
case linux
|
case linux
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
protocol Platform: Codable {
|
protocol Platform: Codable {
|
||||||
func os() -> OS
|
func os() -> OS
|
||||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import Puppy
|
|||||||
var puppy = Puppy.default
|
var puppy = Puppy.default
|
||||||
|
|
||||||
class LogFormatter: LogFormattable {
|
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 {
|
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)"
|
"\(date) \(level) \(message)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -20,6 +20,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Clone.self,
|
Clone.self,
|
||||||
Run.self,
|
Run.self,
|
||||||
Set.self,
|
Set.self,
|
||||||
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
@@ -27,6 +28,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Push.self,
|
Push.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
Rename.self,
|
Rename.self,
|
||||||
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ func resolveBinaryPath(_ name: String) -> URL? {
|
|||||||
|
|
||||||
for pathComponent in path.split(separator: ":") {
|
for pathComponent in path.split(separator: ":") {
|
||||||
let url = URL(fileURLWithPath: String(pathComponent))
|
let url = URL(fileURLWithPath: String(pathComponent))
|
||||||
.appendingPathComponent(name, isDirectory: false)
|
.appendingPathComponent(name, isDirectory: false)
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.path) {
|
if FileManager.default.fileExists(atPath: url.path) {
|
||||||
return url
|
return url
|
||||||
|
|||||||
+33
-37
@@ -14,8 +14,8 @@ struct DownloadFailed: Error {
|
|||||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||||
let description: String
|
let description: String
|
||||||
|
|
||||||
init(_ what: String, _ plural: String) {
|
init(_ what: String, _ plural: String, _ requires: String = "running macOS 13.0 (Ventura) or newer") {
|
||||||
description = "error: \(what) \(plural) only supported on hosts 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
|
// VM's config
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
// VM's config
|
// VM's config
|
||||||
var config: VMConfig
|
var config: VMConfig
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
init(vmDir: VMDirectory,
|
init(vmDir: VMDirectory,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||||
directoryShares: [DirectoryShare] = []
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||||
) throws {
|
) throws {
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
@@ -52,9 +52,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
self.network = network
|
self.network = network
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||||
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
network: network, additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: directoryShares)
|
directorySharingDevices: directorySharingDevices
|
||||||
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
@@ -63,9 +64,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
// Check if we already have this IPSW in cache
|
// Check if we already have this IPSW in cache
|
||||||
let (channel, response) = try await Fetcher().fetch(URLRequest(url: 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")
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
@@ -121,7 +122,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||||
virtualMachine.state == VZVirtualMachine.State.paused ||
|
virtualMachine.state == VZVirtualMachine.State.paused ||
|
||||||
virtualMachine.state == VZVirtualMachine.State.error
|
virtualMachine.state == VZVirtualMachine.State.error
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +131,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
ipswURL: URL,
|
ipswURL: URL,
|
||||||
diskSizeGB: UInt16,
|
diskSizeGB: UInt16,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||||
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = []
|
||||||
) async throws {
|
) async throws {
|
||||||
var ipswURL = ipswURL
|
var ipswURL = ipswURL
|
||||||
|
|
||||||
@@ -138,6 +140,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
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
|
// Load the restore image and try to get the requirements
|
||||||
// that match both the image and our platform
|
// that match both the image and our platform
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
@@ -170,9 +177,10 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Initialize the virtual machine and its configuration
|
// Initialize the virtual machine and its configuration
|
||||||
self.network = network
|
self.network = network
|
||||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||||
vmConfig: config, network: network,
|
vmConfig: config, network: network,
|
||||||
additionalDiskAttachments: additionalDiskAttachments,
|
additionalDiskAttachments: additionalDiskAttachments,
|
||||||
directoryShares: [])
|
directorySharingDevices: directorySharingDevices
|
||||||
|
)
|
||||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
@@ -211,7 +219,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
func run(_ recovery: Bool) async throws {
|
func run(_ recovery: Bool) async throws {
|
||||||
try network.run(sema)
|
try network.run(sema)
|
||||||
|
|
||||||
DispatchQueue.main.sync {
|
let startTask = DispatchQueue.main.sync {
|
||||||
Task {
|
Task {
|
||||||
if #available(macOS 13, *) {
|
if #available(macOS 13, *) {
|
||||||
// new API introduced in Ventura
|
// 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()
|
sema.wait()
|
||||||
}, onCancel: {
|
}, onCancel: {
|
||||||
sema.signal()
|
sema.signal()
|
||||||
@@ -248,7 +261,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
vmConfig: VMConfig,
|
vmConfig: VMConfig,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||||
directoryShares: [DirectoryShare]
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration]
|
||||||
) throws -> VZVirtualMachineConfiguration {
|
) throws -> VZVirtualMachineConfiguration {
|
||||||
let configuration = VZVirtualMachineConfiguration()
|
let configuration = VZVirtualMachineConfiguration()
|
||||||
|
|
||||||
@@ -292,19 +305,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
// Entropy
|
// Entropy
|
||||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||||
|
|
||||||
// Directory share
|
// Directory sharing devices
|
||||||
if #available(macOS 13, *) {
|
configuration.directorySharingDevices = directorySharingDevices
|
||||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
|
||||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
|
||||||
|
|
||||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
|
||||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
|
||||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
|
||||||
|
|
||||||
configuration.directorySharingDevices = [sharingDevice]
|
|
||||||
} else if !directoryShares.isEmpty {
|
|
||||||
throw UnsupportedOSError("directory sharing", "is")
|
|
||||||
}
|
|
||||||
|
|
||||||
try configuration.validate()
|
try configuration.validate()
|
||||||
|
|
||||||
@@ -317,7 +319,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
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()
|
sema.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +328,3 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
sema.signal()
|
sema.signal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct DirectoryShare {
|
|
||||||
let name: String
|
|
||||||
let path: URL
|
|
||||||
let readOnly: Bool
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-10
@@ -48,10 +48,10 @@ struct VMConfig: Codable {
|
|||||||
var display: VMDisplayConfig = VMDisplayConfig()
|
var display: VMDisplayConfig = VMDisplayConfig()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
cpuCountMin: Int,
|
cpuCountMin: Int,
|
||||||
memorySizeMin: UInt64,
|
memorySizeMin: UInt64,
|
||||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||||
) {
|
) {
|
||||||
self.os = platform.os()
|
self.os = platform.os()
|
||||||
self.arch = CurrentArchitecture()
|
self.arch = CurrentArchitecture()
|
||||||
@@ -105,12 +105,12 @@ struct VMConfig: Codable {
|
|||||||
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
|
let encodedMacAddress = try container.decode(String.self, forKey: .macAddress)
|
||||||
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
|
guard let macAddress = VZMACAddress.init(string: encodedMacAddress) else {
|
||||||
throw DecodingError.dataCorruptedError(
|
throw DecodingError.dataCorruptedError(
|
||||||
forKey: .hardwareModel,
|
forKey: .hardwareModel,
|
||||||
in: container,
|
in: container,
|
||||||
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
debugDescription: "failed to initialize VZMacAddress using the provided value")
|
||||||
}
|
}
|
||||||
self.macAddress = macAddress
|
self.macAddress = macAddress
|
||||||
|
|
||||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ struct VMConfig: Codable {
|
|||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
if cpuCount < cpuCountMin {
|
if cpuCount < cpuCountMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
throw LessThanMinimalResourcesError("VM should have \(cpuCountMin) CPU cores"
|
||||||
+ " at minimum (requested \(cpuCount))")
|
+ " at minimum (requested \(cpuCount))")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cpuCount = cpuCount
|
self.cpuCount = cpuCount
|
||||||
@@ -141,7 +141,7 @@ struct VMConfig: Codable {
|
|||||||
mutating func setMemory(memorySize: UInt64) throws {
|
mutating func setMemory(memorySize: UInt64) throws {
|
||||||
if memorySize < memorySizeMin {
|
if memorySize < memorySizeMin {
|
||||||
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
throw LessThanMinimalResourcesError("VM should have \(memorySizeMin) bytes"
|
||||||
+ " of memory at minimum (requested \(memorySizeMin))")
|
+ " of memory at minimum (requested \(memorySizeMin))")
|
||||||
}
|
}
|
||||||
|
|
||||||
self.memorySize = memorySize
|
self.memorySize = memorySize
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
let diskCompressedSize: Int64 = Int64(diskLayers.map {
|
||||||
$0.size
|
$0.size
|
||||||
}
|
}
|
||||||
.reduce(0) {
|
.reduce(0) {
|
||||||
$0 + $1
|
$0 + $1
|
||||||
})
|
})
|
||||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||||
@@ -144,9 +144,9 @@ extension VMDirectory {
|
|||||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
layers: layers,
|
layers: layers,
|
||||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct UninitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlreadyInitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
@@ -47,7 +41,7 @@ struct VMDirectory: Prunable {
|
|||||||
|
|
||||||
func initialize(overwrite: Bool = false) throws {
|
func initialize(overwrite: Bool = false) throws {
|
||||||
if !overwrite && initialized {
|
if !overwrite && initialized {
|
||||||
throw AlreadyInitializedVMDirectoryError()
|
throw RuntimeError("VM directory is already initialized, preventing overwrite")
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -58,8 +52,13 @@ struct VMDirectory: Prunable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
|
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||||
|
throw RuntimeError("the specified VM does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
throw UninitializedVMDirectoryError()
|
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||||
|
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
|
// Pre-create intermediate directories (e.g. creates ~/.tart/cache/OCIs/github.com/org/repo/
|
||||||
// for github.com/org/repo:latest)
|
// for github.com/org/repo:latest)
|
||||||
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
try FileManager.default.createDirectory(at: targetURL.deletingLastPathComponent(),
|
||||||
withIntermediateDirectories: true)
|
withIntermediateDirectories: true)
|
||||||
|
|
||||||
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
_ = try FileManager.default.replaceItemAt(targetURL, withItemAt: from.baseURL)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
var refCounts = Dictionary<URL, UInt>()
|
var refCounts = Dictionary<URL, UInt>()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
var result: [(String, VMDirectory, Bool)] = Array()
|
var result: [(String, VMDirectory, Bool)] = Array()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
|
|
||||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: Digest.hash(manifestData)))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
// Ensure that host directory for given RemoteName exists in OCI storage
|
// Ensure that host directory for given RemoteName exists in OCI storage
|
||||||
let hostDirectoryURL = hostDirectoryURL(digestName)
|
let hostDirectoryURL = hostDirectoryURL(digestName)
|
||||||
@@ -165,15 +165,15 @@ class VMStorageOCI: PrunableStorage {
|
|||||||
|
|
||||||
if capacityImportant == 0 || capacityAvailable == 0 {
|
if capacityImportant == 0 || capacityAvailable == 0 {
|
||||||
puppy.warning("important capacity \(capacityImportant) bytes, "
|
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.
|
// There is a suspicious that occasionally capacity is returned as zero which can't be true.
|
||||||
// Let's validate to avoid unnecessary pruning.
|
// Let's validate to avoid unnecessary pruning.
|
||||||
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
if 0 < availableCapacityBytes && availableCapacityBytes < requiredCapacityBytes {
|
||||||
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
|
puppy.info("pruning cache to accommodate \(name) with a disk of size \(uncompressedDiskSize) bytes ("
|
||||||
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
|
+ "available capacity is \(availableCapacityBytes) bytes, required capacity "
|
||||||
+ "is \(requiredCapacityBytes) bytes)")
|
+ "is \(requiredCapacityBytes) bytes)")
|
||||||
|
|
||||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,36 +3,36 @@ import Dynamic
|
|||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
class FullFledgedVNC: VNC {
|
class FullFledgedVNC: VNC {
|
||||||
let password: String
|
let password: String
|
||||||
private let vnc: Dynamic
|
private let vnc: Dynamic
|
||||||
|
|
||||||
init(virtualMachine: VZVirtualMachine) {
|
init(virtualMachine: VZVirtualMachine) {
|
||||||
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||||
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||||
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
||||||
securityConfiguration: securityConfiguration)
|
securityConfiguration: securityConfiguration)
|
||||||
vnc.virtualMachine = virtualMachine
|
vnc.virtualMachine = virtualMachine
|
||||||
vnc.start()
|
vnc.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL() async throws -> URL {
|
||||||
while true {
|
while true {
|
||||||
// Port is 0 shortly after start(),
|
// Port is 0 shortly after start(),
|
||||||
// but will be initialized later
|
// but will be initialized later
|
||||||
if let port = vnc.port.asUInt16, port != 0 {
|
if let port = vnc.port.asUInt16, port != 0 {
|
||||||
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
||||||
}
|
|
||||||
|
|
||||||
// Wait 50 ms.
|
|
||||||
try await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func stop() throws {
|
// Wait 50 ms.
|
||||||
vnc.stop()
|
try await Task.sleep(nanoseconds: 50_000_000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
func stop() throws {
|
||||||
try? stop()
|
vnc.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
try? stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,33 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class FileLockTests: XCTestCase {
|
final class FileLockTests: XCTestCase {
|
||||||
func testSimple() throws {
|
func testSimple() throws {
|
||||||
// Create a temporary file that will be used as a lock
|
// Create a temporary file that will be used as a lock
|
||||||
let url = temporaryFile()
|
let url = temporaryFile()
|
||||||
|
|
||||||
// Make sure this file can be locked and unlocked
|
// Make sure this file can be locked and unlocked
|
||||||
let lock = try FileLock(lockURL: url)
|
let lock = try FileLock(lockURL: url)
|
||||||
try lock.lock()
|
try lock.lock()
|
||||||
try lock.unlock()
|
try lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDoubleLockResultsInError() throws {
|
func testDoubleLockResultsInError() throws {
|
||||||
// Create a temporary file that will be used as a lock
|
// Create a temporary file that will be used as a lock
|
||||||
let url = temporaryFile()
|
let url = temporaryFile()
|
||||||
|
|
||||||
// Create two locks on a same file and ensure one of them fails
|
// Create two locks on a same file and ensure one of them fails
|
||||||
let firstLock = try FileLock(lockURL: url)
|
let firstLock = try FileLock(lockURL: url)
|
||||||
try firstLock.lock()
|
try firstLock.lock()
|
||||||
|
|
||||||
let secondLock = try! FileLock(lockURL: url)
|
let secondLock = try! FileLock(lockURL: url)
|
||||||
XCTAssertFalse(try secondLock.trylock())
|
XCTAssertFalse(try secondLock.trylock())
|
||||||
}
|
}
|
||||||
|
|
||||||
private func temporaryFile() -> URL {
|
private func temporaryFile() -> URL {
|
||||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,31 +5,31 @@ import Network
|
|||||||
final class MACAddressResolverTests: XCTestCase {
|
final class MACAddressResolverTests: XCTestCase {
|
||||||
func testSingleEntry() throws {
|
func testSingleEntry() throws {
|
||||||
let leases = try Leases("""
|
let leases = try Leases("""
|
||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
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 {
|
func testMultipleEntries() throws {
|
||||||
let leases = try Leases("""
|
let leases = try Leases("""
|
||||||
{
|
{
|
||||||
ip_address=1.2.3.4
|
ip_address=1.2.3.4
|
||||||
hw_address=1,00:11:22:33:44:55
|
hw_address=1,00:11:22:33:44:55
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
ip_address=5.6.7.8
|
ip_address=5.6.7.8
|
||||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||||
}
|
}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
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"),
|
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")!))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,88 +2,88 @@ import XCTest
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
final class RegistryTests: XCTestCase {
|
final class RegistryTests: XCTestCase {
|
||||||
var registryRunner: RegistryRunner?
|
var registryRunner: RegistryRunner?
|
||||||
|
|
||||||
override func setUp() async throws {
|
override func setUp() async throws {
|
||||||
try await super.setUp()
|
try await super.setUp()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
registryRunner = try await RegistryRunner()
|
registryRunner = try await RegistryRunner()
|
||||||
} catch {
|
} catch {
|
||||||
try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil)
|
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 {
|
// Ensure that both blobs are identical
|
||||||
try await super.tearDown()
|
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 {
|
// Ensure that both blobs are identical
|
||||||
registryRunner!.registry
|
XCTAssertEqual(largeBlobToPush, pulledLargeBlob)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPushPullBlobSmall() async throws {
|
func testPushPullManifest() async throws {
|
||||||
// Generate a simple blob
|
// Craft a basic config
|
||||||
let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8)
|
let configData = try OCIConfig().toJSON()
|
||||||
|
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||||
|
|
||||||
// Push it
|
// Craft a basic layer
|
||||||
let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob)
|
let layerData = Data("doesn't matter".utf8)
|
||||||
XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest)
|
let layerDigest = try await registry.pushBlob(fromData: layerData)
|
||||||
|
|
||||||
// Pull it
|
// Craft a basic manifest and push it
|
||||||
var pulledBlob = Data()
|
let manifest = OCIManifest(
|
||||||
try await registry.pullBlob(pushedBlobDigest) { data in
|
config: OCIManifestConfig(size: configData.count, digest: configDigest),
|
||||||
pulledBlob.append(data)
|
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
|
// Ensure that the manifest pulled by tag matches with the one pushed above
|
||||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest")
|
||||||
}
|
XCTAssertEqual(manifest, pulledByTagManifest)
|
||||||
|
|
||||||
func testPushPullBlobHugeInChunks() async throws {
|
// Ensure that the manifest pulled by digest matches with the one pushed above
|
||||||
// Generate a large enough blob
|
let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)")
|
||||||
let fh = FileHandle(forReadingAtPath: "/dev/urandom")!
|
XCTAssertEqual(manifest, pulledByDigestManifest)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ final class RemoteNameTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testComplexTag() throws {
|
func testComplexTag() throws {
|
||||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: Reference(tag: "1.2.3-RC-1"))
|
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,
|
XCTAssertEqual(expectedRemoteName,
|
||||||
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testASCIIOnly() throws {
|
func testASCIIOnly() throws {
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import XCTest
|
|||||||
final class URLAbsolutizationTets: XCTestCase {
|
final class URLAbsolutizationTets: XCTestCase {
|
||||||
func testNeedsAbsolutization() throws {
|
func testNeedsAbsolutization() throws {
|
||||||
let url = URL(string: "/v2/some/path?some=query")!
|
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")
|
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDoesntNeedAbsolutization() throws {
|
func testDoesntNeedAbsolutization() throws {
|
||||||
let url = URL(string: "https://example.org/v2/some/path?some=query")!
|
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")
|
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,53 +2,53 @@ import Foundation
|
|||||||
@testable import tart
|
@testable import tart
|
||||||
|
|
||||||
enum RegistryRunnerError: Error {
|
enum RegistryRunnerError: Error {
|
||||||
case DockerFailed(exitCode: Int32)
|
case DockerFailed(exitCode: Int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
class RegistryRunner {
|
class RegistryRunner {
|
||||||
let containerID: String
|
let containerID: String
|
||||||
let registry: Registry
|
let registry: Registry
|
||||||
|
|
||||||
static func dockerCmd(_ arguments: String...) throws -> String {
|
static func dockerCmd(_ arguments: String...) throws -> String {
|
||||||
let stdoutPipe = Pipe()
|
let stdoutPipe = Pipe()
|
||||||
|
|
||||||
let proc = Process()
|
let proc = Process()
|
||||||
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker")
|
||||||
proc.arguments = arguments
|
proc.arguments = arguments
|
||||||
proc.standardOutput = stdoutPipe
|
proc.standardOutput = stdoutPipe
|
||||||
try proc.run()
|
try proc.run()
|
||||||
|
|
||||||
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
proc.waitUntilExit()
|
proc.waitUntilExit()
|
||||||
|
|
||||||
if proc.terminationStatus != 0 {
|
if proc.terminationStatus != 0 {
|
||||||
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus)
|
||||||
}
|
|
||||||
|
|
||||||
return String(data: stdoutData, encoding: .utf8) ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init() async throws {
|
return String(data: stdoutData, encoding: .utf8) ?? ""
|
||||||
// Start container
|
}
|
||||||
let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2")
|
|
||||||
.trimmingCharacters(in: CharacterSet.newlines)
|
|
||||||
containerID = container
|
|
||||||
|
|
||||||
// Get forwarded port
|
init() async throws {
|
||||||
let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}")
|
// Start container
|
||||||
.trimmingCharacters(in: CharacterSet.newlines)
|
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/")!,
|
// Get forwarded port
|
||||||
namespace: "vm-image")
|
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
|
registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!,
|
||||||
while ((try? await registry.ping()) == nil) {
|
namespace: "vm-image")
|
||||||
try await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
}
|
// Wait for the Docker Registry to start
|
||||||
|
while ((try? await registry.ping()) == nil) {
|
||||||
|
try await Task.sleep(nanoseconds: 100_000_000)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
_ = try! Self.dockerCmd("kill", containerID)
|
_ = try! Self.dockerCmd("kill", containerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pytest
|
||||||
|
testcontainers
|
||||||
|
requests
|
||||||
|
bitmath
|
||||||
|
pytest-dependency
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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 == ""
|
||||||
@@ -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])
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user