mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0105280b5d | ||
|
|
c063c5bdc2 | ||
|
|
8a2b0151e0 | ||
|
|
2eea51d6ec | ||
|
|
1890909d28 | ||
|
|
0cad2e454c | ||
|
|
17dcf942c1 | ||
|
|
6296df7c0c | ||
|
|
c54b140750 | ||
|
|
048a5506df | ||
|
|
553b36349b | ||
|
|
d0bf286aa1 | ||
|
|
195a4b3a72 | ||
|
|
59393df2e1 | ||
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a | ||
|
|
a80954c888 | ||
|
|
cc8201dee6 | ||
|
|
2cab49b3f1 | ||
|
|
131827802a | ||
|
|
e2b7f12388 | ||
|
|
617a5d02dc | ||
|
|
b83bce4544 | ||
|
|
7d16516b6a | ||
|
|
56ddd8df75 | ||
|
|
b1534a05d5 | ||
|
|
f54e7c2cab | ||
|
|
85dfa961c9 | ||
|
|
3a74fc35e6 | ||
|
|
7bf7f890c4 | ||
|
|
48cd4b47e4 | ||
|
|
c1dee4f9b2 | ||
|
|
116dc01f55 | ||
|
|
52abb7589c | ||
|
|
4386192161 | ||
|
|
85429cea0a | ||
|
|
384abcd0bd | ||
|
|
92529afa23 | ||
|
|
c25364b2d4 | ||
|
|
e12f95878e | ||
|
|
7efef28250 | ||
|
|
1e90752ded | ||
|
|
2020324ef5 | ||
|
|
b581db5be4 | ||
|
|
8ed2ce159f | ||
|
|
89217c23a2 | ||
|
|
0d633de04a | ||
|
|
f59ef2722d | ||
|
|
7759c72a76 | ||
|
|
4cc8a9925a | ||
|
|
b16bbf587a | ||
|
|
022317bc17 |
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
export VERSION="${CIRRUS_TAG:-0}"
|
||||
|
||||
mkdir -p .ci/pkg/
|
||||
cp .build/arm64-apple-macosx/debug/tart .ci/pkg/
|
||||
pkgbuild --root .ci/pkg --version $VERSION --install-location /usr/local/bin/ --identifier com.github.cirruslabs.tart --sign "Developer ID Installer: Fedor Korotkov (9M2P8L4D89)" "./dist/Tart-$VERSION.pkg"
|
||||
xcrun notarytool submit "./dist/Tart-$VERSION.pkg" --keychain-profile "notarytool" --wait
|
||||
xcrun stapler staple "./dist/Tart-$VERSION.pkg"
|
||||
+27
-5
@@ -1,14 +1,16 @@
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: Mac-Mini-M1
|
||||
|
||||
task:
|
||||
name: Test
|
||||
name: Test on Ventura
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: Mac-Mini-M1
|
||||
build_script: swift test
|
||||
test_script: swift test
|
||||
|
||||
task:
|
||||
name: Build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
@@ -17,7 +19,27 @@ task:
|
||||
task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||
env:
|
||||
MACOS_CERTIFICATE: ENCRYPTED[8a6930a8c1286e7e536ea41b7647ea40e99174ad15e9cfcc753754fea55a619b355415629dff515b54a8921643e314e5]
|
||||
AC_PASSWORD: ENCRYPTED[4a761023e7e06fe2eb350c8b6e8e7ca961af193cb9ba47605f25f1d353abc3142606f412e405be48fd897a78787ea8c2]
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
setup_script:
|
||||
- cd $HOME
|
||||
- echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12
|
||||
- security create-keychain -p password101 build.keychain
|
||||
- security default-keychain -s build.keychain
|
||||
- security unlock-keychain -p password101 build.keychain
|
||||
- security import certificate.p12 -k build.keychain -P password101 -T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
- security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k password101 build.keychain
|
||||
- xcrun notarytool store-credentials "notarytool" --apple-id "hello@cirruslabs.org" --team-id "9M2P8L4D89" --password $AC_PASSWORD
|
||||
install_script:
|
||||
- brew install go goreleaser/tap/goreleaser-pro
|
||||
- brew install mitchellh/gon/gon
|
||||
info_script:
|
||||
- security find-identity -v
|
||||
- xcodebuild -version
|
||||
- swift -version
|
||||
release_script: goreleaser
|
||||
|
||||
+17
-4
@@ -7,13 +7,16 @@ builds:
|
||||
goarch:
|
||||
- arm64
|
||||
prebuilt:
|
||||
path: .build/{{ .Arch }}-apple-macosx/release/tart
|
||||
path: .build/arm64-apple-macosx/debug/tart
|
||||
hooks:
|
||||
post:
|
||||
- gon gon.hcl
|
||||
- .ci/create-pkg.sh
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c release --product tart
|
||||
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/release/tart
|
||||
- swift build -c debug --product tart
|
||||
|
||||
archives:
|
||||
- id: binary
|
||||
@@ -24,6 +27,8 @@ archives:
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./dist/Tart-{{ .Tag }}.pkg
|
||||
|
||||
brews:
|
||||
- name: tart
|
||||
@@ -32,9 +37,17 @@ brews:
|
||||
tap:
|
||||
owner: cirruslabs
|
||||
name: homebrew-cli
|
||||
caveats: See the Github repository for more information
|
||||
caveats: See the GitHub repository for more information
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
dependencies:
|
||||
- "cirruslabs/cli/softnet"
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
on_macos do
|
||||
unless Hardware::CPU.arm?
|
||||
odie "Tart only works on Apple Silicon!"
|
||||
end
|
||||
end
|
||||
|
||||
+40
-4
@@ -5,8 +5,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/async-http-client",
|
||||
"state" : {
|
||||
"revision" : "24425989dadab6d6e4167174791a23d4e2a6d0c3",
|
||||
"version" : "1.10.0"
|
||||
"revision" : "df87a860fdc41a595d5ca67f74cde9adbccc099a",
|
||||
"version" : "1.11.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -18,6 +18,15 @@
|
||||
"revision" : "772883073d044bc754d401cabb6574624eb3778f"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-algorithms",
|
||||
"state" : {
|
||||
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -27,6 +36,15 @@
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "919eb1d83e02121cdb434c7bfc1f0c66ef17febe",
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -68,8 +86,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-http2.git",
|
||||
"state" : {
|
||||
"revision" : "72bcaf607b40d7c51044f65b0f5ed8581a911832",
|
||||
"version" : "1.21.0"
|
||||
"revision" : "108ac15087ea9b79abb6f6742699cf31de0e8772",
|
||||
"version" : "1.22.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -90,6 +108,15 @@
|
||||
"version" : "1.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-numerics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-numerics",
|
||||
"state" : {
|
||||
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
|
||||
"version" : "1.0.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-parsing",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -99,6 +126,15 @@
|
||||
"version" : "0.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftdate",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/malcommac/SwiftDate",
|
||||
"state" : {
|
||||
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
|
||||
"version" : "6.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
// swift-tools-version:5.6
|
||||
// swift-tools-version:5.7
|
||||
|
||||
import PackageDescription
|
||||
let package = Package(
|
||||
@@ -13,14 +13,18 @@ let package = Package(
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||
.package(url: "https://github.com/mhdhejazi/Dynamic", branch: "master"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
|
||||
.package(url: "https://github.com/swift-server/async-http-client", from: "1.10.0"),
|
||||
.package(url: "https://github.com/swift-server/async-http-client", from: "1.11.4"),
|
||||
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
|
||||
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "Algorithms", package: "swift-algorithms"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||
.product(name: "Dynamic", package: "Dynamic"),
|
||||
.product(name: "Parsing", package: "swift-parsing"),
|
||||
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||
]),
|
||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||

|
||||
|
||||
*Tart* is a virtualization toolset to build, run and manage virtual machines on Apple Silicon.
|
||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||
|
||||
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/14966395?baseline=14966339).
|
||||
@@ -55,10 +55,40 @@ config from above will just work in Cirrus CI and your tasks will be executed in
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
### Retrieving artifacts from within Tart VMs
|
||||
|
||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
||||
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||
exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tasks/#artifacts-instruction):
|
||||
|
||||
```yaml
|
||||
task:
|
||||
name: Build
|
||||
macos_instance:
|
||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
|
||||
build_script: swift build --product tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
```
|
||||
|
||||
Running Cirrus CLI with `--artifacts-dir` will write defined `artifacts` to the provided local directory on the host:
|
||||
|
||||
```bash
|
||||
cirrus run --artifacts-dir artifacts
|
||||
```
|
||||
|
||||
Note that all retrieved artifacts will be prefixed with the associated task name and `artifacts` instruction name.
|
||||
For the example above, `tart` binary will be saved to `$PWD/artifacts/Build/binary/.build/debug/tart`.
|
||||
|
||||
## Virtual Machine Management
|
||||
|
||||
### Creating from scratch
|
||||
|
||||
Tart supports macOS and Linux virtual machines. All commands like `run` and `pull` work the same way regarding of the underlying OS a particular VM image has.
|
||||
The only difference is how such VM images are created. Please check sections below for [macOS](#creating-a-macos-vm-image-from-scratch) and [Linux](#creating-a-linux-vm-image-from-scratch) instructions.
|
||||
|
||||
#### Creating a macOS VM image from scratch
|
||||
|
||||
Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` file [here](https://ipsw.me/) or you can
|
||||
use `latest` instead of a path to `*.ipsw` to download the latest available version:
|
||||
|
||||
@@ -75,6 +105,27 @@ After the initial booting of the VM you'll need to manually go through the macOS
|
||||
4. Disable Screen Saver.
|
||||
5. Run `sudo visudo` in Terminal, find `%admin ALL=(ALL) ALL` add `admin ALL=(ALL) NOPASSWD: ALL` to allow sudo without a password.
|
||||
|
||||
#### Creating a Linux VM image from scratch
|
||||
|
||||
```bash
|
||||
# Create a bare VM
|
||||
tart create --linux ubuntu
|
||||
|
||||
# Install Ubuntu
|
||||
tart run --disk focal-desktop-arm64.iso ubuntu
|
||||
|
||||
# Run VM
|
||||
tart run ubuntu
|
||||
```
|
||||
|
||||
After the initial setup please make sure your VM can be SSH-ed into by running the following commands inside your VM:
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install -y openssh-server
|
||||
sudo ufw allow ssh
|
||||
```
|
||||
|
||||
### Configuring a VM
|
||||
|
||||
By default, a tart VM uses 2 CPUs and 4 GB of memory with a `1024x768` display. This can be changed with `tart set` command.
|
||||
@@ -140,10 +191,20 @@ tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:
|
||||
|
||||
#### Pulling a Remote Image
|
||||
|
||||
You can either pull an image:
|
||||
|
||||
```shell
|
||||
tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
tart pull acme.io/remoteorg/name:latest
|
||||
```
|
||||
|
||||
...or instantiate a VM from a remote image:
|
||||
|
||||
```shell
|
||||
tart clone acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
This invocation calls the `tart pull` implicitly (if the image is not being present) before doing the actual cloning.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ struct Clone: AsyncParsableCommand {
|
||||
@Argument(help: "new VM name")
|
||||
var newName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if newName.contains("/") {
|
||||
throw ValidationError("<new-name> should be a local name")
|
||||
@@ -24,7 +27,7 @@ struct Clone: AsyncParsableCommand {
|
||||
|
||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
try await ociStorage.pull(remoteName, registry: registry)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,15 +9,18 @@ struct Create: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: ArgumentHelp("Path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
@Option(help: ArgumentHelp("create a macOS VM using path to the IPSW file (or \"latest\") to fetch the latest appropriate IPSW", valueName: "path"))
|
||||
var fromIPSW: String?
|
||||
|
||||
@Flag(help: "create a Linux VM")
|
||||
var linux: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt8 = 50
|
||||
var diskSize: UInt16 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
throw ValidationError("Please specify a --from-ipsw option!")
|
||||
if fromIPSW == nil && !linux {
|
||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +28,20 @@ struct Create: AsyncParsableCommand {
|
||||
do {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
if fromIPSW! == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW!), diskSizeGB: diskSize)
|
||||
if let fromIPSW = fromIPSW {
|
||||
if fromIPSW == "latest" {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
} else {
|
||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: URL(fileURLWithPath: fromIPSW), diskSizeGB: diskSize)
|
||||
}
|
||||
}
|
||||
|
||||
if linux {
|
||||
if #available(macOS 13, *) {
|
||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
}
|
||||
|
||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||
|
||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
||||
do {
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
|
||||
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
|
||||
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
print(ip)
|
||||
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||
}
|
||||
|
||||
print(ipViaDHCP)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -33,12 +40,11 @@ struct IP: AsyncParsableCommand {
|
||||
}
|
||||
}
|
||||
|
||||
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
||||
|
||||
repeat {
|
||||
if let ip = try ARPCache.ResolveMACAddress(macAddress: vmMacAddress) {
|
||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
||||
return ip
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ struct List: AsyncParsableCommand {
|
||||
print("Source\tName")
|
||||
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
displayTable("oci", try VMStorageOCI().list())
|
||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
|
||||
@@ -8,15 +8,44 @@ struct Login: AsyncParsableCommand {
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
@Option(help: "username")
|
||||
var username: String?
|
||||
|
||||
@Flag(help: "password-stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
let usernameProvided = username != nil
|
||||
let passwordProvided = passwordStdin
|
||||
|
||||
if usernameProvided != passwordProvided {
|
||||
throw ValidationError("both --username and --password-stdin are required")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let (user, password) = try StdinCredentials.retrieve()
|
||||
var user: String
|
||||
var password: String
|
||||
|
||||
if let username = username {
|
||||
user = username
|
||||
|
||||
let passwordData = FileHandle.standardInput.readDataToEndOfFile()
|
||||
password = String(decoding: passwordData, as: UTF8.self)
|
||||
} else {
|
||||
(user, password) = try StdinCredentials.retrieve()
|
||||
}
|
||||
let credentialsProvider = DictionaryCredentialsProvider([
|
||||
host: (user, password)
|
||||
])
|
||||
|
||||
do {
|
||||
let registry = try Registry(host: host, namespace: "", credentialsProvider: credentialsProvider)
|
||||
let registry = try Registry(host: host, namespace: "", insecure: insecure,
|
||||
credentialsProviders: [credentialsProvider])
|
||||
try await registry.ping()
|
||||
} catch {
|
||||
print("invalid credentials: \(error)")
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
import SwiftDate
|
||||
|
||||
struct Prune: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
|
||||
|
||||
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
|
||||
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
|
||||
valueName: "n"))
|
||||
var olderThan: UInt?
|
||||
|
||||
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
|
||||
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
|
||||
valueName: "n"))
|
||||
var cacheBudget: UInt?
|
||||
|
||||
@Flag(help: .hidden)
|
||||
var gc: Bool = false
|
||||
|
||||
func validate() throws {
|
||||
if olderThan == nil && cacheBudget == nil && !gc {
|
||||
throw ValidationError("at least one pruning criteria must be specified")
|
||||
}
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
if gc {
|
||||
try VMStorageOCI().gc()
|
||||
}
|
||||
|
||||
// Clean up cache entries based on last accessed date
|
||||
if let olderThan = olderThan {
|
||||
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
|
||||
let olderThanDate = Date().addingTimeInterval(olderThanInterval)
|
||||
|
||||
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
|
||||
}
|
||||
|
||||
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
||||
if let cacheBudget = cacheBudget {
|
||||
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneOlderThan(olderThanDate: Date) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
|
||||
|
||||
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
|
||||
}
|
||||
|
||||
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
|
||||
var it = prunables.makeIterator()
|
||||
|
||||
while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
}
|
||||
|
||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
}
|
||||
}
|
||||
|
||||
static func pruneReclaim(reclaimBytes: UInt64) throws {
|
||||
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
||||
let prunables: [Prunable] = try prunableStorages
|
||||
.flatMap { try $0.prunables() }
|
||||
.sorted { try $0.accessDate() < $1.accessDate() }
|
||||
|
||||
// Does it even make sense to start?
|
||||
let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
|
||||
if cacheUsedBytes < reclaimBytes {
|
||||
return
|
||||
}
|
||||
|
||||
var cacheReclaimedBytes: Int = 0
|
||||
|
||||
var it = prunables.makeIterator()
|
||||
|
||||
while cacheReclaimedBytes <= reclaimBytes {
|
||||
guard let prunable = it.next() else {
|
||||
break
|
||||
}
|
||||
|
||||
cacheReclaimedBytes -= try prunable.sizeBytes()
|
||||
try prunable.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ struct Pull: AsyncParsableCommand {
|
||||
@Argument(help: "remote VM name")
|
||||
var remoteName: String
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
|
||||
@@ -12,6 +12,17 @@ struct Push: AsyncParsableCommand {
|
||||
@Argument(help: "remote VM name(s)")
|
||||
var remoteNames: [String]
|
||||
|
||||
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||
var insecure: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("chunk size in MB if registry supports chunked uploads",
|
||||
discussion: """
|
||||
By default monolithic method is used for uploading blobs to the registry but some registries support a more efficient chunked method.
|
||||
For example, AWS Elastic Container Registry supports only chunks larger than 5MB but GitHub Container Registry supports only chunks smaller than 4MB. Google Container Registry on the other hand doesn't support chunked uploads at all.
|
||||
Please refer to the documentation of your particular registry in order to see if this option is suitable for you and what's the recommended chunk size.
|
||||
"""))
|
||||
var chunkSize: Int = 0
|
||||
|
||||
@Flag(help: ArgumentHelp("cache pushed images locally",
|
||||
discussion: "Increases disk usage, but saves time if you're going to pull the pushed images later."))
|
||||
var populateCache: Bool = false
|
||||
@@ -37,12 +48,17 @@ struct Push: AsyncParsableCommand {
|
||||
|
||||
// Push VM
|
||||
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)
|
||||
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(remoteNamesForRegistry.referenceNames())...")
|
||||
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference.value })
|
||||
let pushedRemoteName = try await localVMDir.pushToRegistry(
|
||||
registry: registry,
|
||||
references: remoteNamesForRegistry.map{ $0.reference.value },
|
||||
chunkSizeMb: chunkSize
|
||||
)
|
||||
|
||||
// Populate the local cache (if requested)
|
||||
if populateCache {
|
||||
|
||||
+132
-36
@@ -24,58 +24,152 @@ struct Run: AsyncParsableCommand {
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use screen sharing instead of the built-in UI.",
|
||||
discussion: "Useful since VNC supports copy/paste, drag and drop, etc.\nNote that Remote Login option should be enabled inside the VM."))
|
||||
discussion: "Useful since Screen Sharing supports copy/paste, drag and drop, etc.\n"
|
||||
+ "Note that Remote Login option should be enabled inside the VM."))
|
||||
var vnc: Bool = false
|
||||
|
||||
@Flag(help: ArgumentHelp(
|
||||
"Use Virtualization.Framework's VNC server instead of the build-in UI.",
|
||||
discussion: "Useful since this type of VNC is available in recovery mode and in macOS installation.\n"
|
||||
+ "Note that this feature is experimental and there may be bugs present when using VNC."))
|
||||
var vncExperimental: Bool = false
|
||||
|
||||
@Flag var withSoftnet: Bool = false
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional disk attachments with an optional read-only specifier\n(e.g. --disk=\"disk.bin\" --disk=\"ubuntu.iso:ro\")
|
||||
""", discussion: """
|
||||
Learn how to create a disk image using Disk Utility here:
|
||||
https://support.apple.com/en-gb/guide/disk-utility/dskutl11888/mac
|
||||
""", valueName: "path[:ro]"))
|
||||
var disk: [String] = []
|
||||
|
||||
@Option(help: ArgumentHelp("""
|
||||
Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\")
|
||||
""", discussion: """
|
||||
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".
|
||||
""", valueName: "name:path[:ro]"))
|
||||
var dir: [String] = []
|
||||
|
||||
func validate() throws {
|
||||
if vnc && vncExperimental {
|
||||
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
if recovery && vnc {
|
||||
print("You can't run in recovery and use VNC!")
|
||||
Foundation.exit(1)
|
||||
}
|
||||
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir)
|
||||
vm = try VM(
|
||||
vmDir: vmDir,
|
||||
withSoftnet: withSoftnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments(),
|
||||
directoryShares: directoryShares()
|
||||
)
|
||||
|
||||
await withThrowingTaskGroup(of: Void.self) { group in
|
||||
let vncImpl: VNC? = try {
|
||||
if vnc {
|
||||
group.addTask(operation: {
|
||||
do {
|
||||
print("Waiting for the VM to boot...")
|
||||
let resolvedIP = try await IP.resolveIP(vm!.config, secondsToWait: 60)
|
||||
guard let ip = resolvedIP else {
|
||||
throw IPNotFound()
|
||||
}
|
||||
let url = URL(string: "vnc://\(ip)")!
|
||||
print("Opening \(url)")
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
print("Failed to get an IP for screen sharing: \(error)")
|
||||
}
|
||||
})
|
||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
return ScreenSharingVNC(vmConfig: vmConfig)
|
||||
} else if vncExperimental {
|
||||
return FullFledgedVNC(virtualMachine: vm!.virtualMachine)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
group.addTask {
|
||||
do {
|
||||
try await vm!.run(recovery)
|
||||
}()
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
let task = Task {
|
||||
do {
|
||||
if let vncImpl = vncImpl {
|
||||
let vncURL = try await vncImpl.waitForURL()
|
||||
|
||||
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
|
||||
print("VNC server is running at \(vncURL)")
|
||||
} else {
|
||||
print(error)
|
||||
print("Opening \(vncURL)...")
|
||||
NSWorkspace.shared.open(vncURL)
|
||||
}
|
||||
}
|
||||
|
||||
Foundation.exit(1)
|
||||
try await vm!.run(recovery)
|
||||
|
||||
if let vncImpl = vncImpl {
|
||||
try vncImpl.stop()
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
||||
print("Virtual machine \"\(name)\" is already running!")
|
||||
Foundation.exit(2)
|
||||
}
|
||||
|
||||
print(error)
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
if noGraphics || vnc || vncExperimental {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
}
|
||||
|
||||
func additionalDiskAttachments() throws -> [VZDiskImageStorageDeviceAttachment] {
|
||||
var result: [VZDiskImageStorageDeviceAttachment] = []
|
||||
let readOnlySuffix = ":ro"
|
||||
|
||||
for rawDisk in disk {
|
||||
if rawDisk.hasSuffix(readOnlySuffix) {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: String(rawDisk.prefix(rawDisk.count - readOnlySuffix.count))),
|
||||
readOnly: true
|
||||
))
|
||||
} else {
|
||||
result.append(try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(fileURLWithPath: rawDisk),
|
||||
readOnly: false
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func directoryShares() throws -> [DirectoryShare] {
|
||||
var result: [DirectoryShare] = []
|
||||
|
||||
for rawDir in dir {
|
||||
let splits = rawDir.split(maxSplits: 2) { $0 == ":" }
|
||||
|
||||
if splits.count < 2 {
|
||||
throw ValidationError("invalid --dir syntax: should at least include name and path, colon-separated")
|
||||
}
|
||||
|
||||
var readOnly: Bool = false
|
||||
|
||||
if splits.count == 3 {
|
||||
if splits[2] == "ro" {
|
||||
readOnly = true
|
||||
} else {
|
||||
throw ValidationError("invalid --dir syntax: optional read-only specifier can only be \"ro\"")
|
||||
}
|
||||
}
|
||||
|
||||
if noGraphics || vnc {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
let (name, path) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
result.append(DirectoryShare(name: name, path: URL(fileURLWithPath: path), readOnly: readOnly))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
@@ -91,6 +185,8 @@ struct Run: AsyncParsableCommand {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}.onDisappear {
|
||||
NSApplication.shared.terminate(self)
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
|
||||
@@ -17,7 +17,7 @@ struct Set: AsyncParsableCommand {
|
||||
var display: VMDisplayConfig?
|
||||
|
||||
@Option(help: .hidden)
|
||||
var diskSize: UInt8?
|
||||
var diskSize: UInt16?
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import Foundation
|
||||
|
||||
struct Config {
|
||||
public static let tartHomeDir: URL = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
let tartHomeDir: URL
|
||||
let tartCacheDir: URL
|
||||
|
||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
init() {
|
||||
var tartHomeDir: URL
|
||||
|
||||
if let customTartHome = ProcessInfo.processInfo.environment["TART_HOME"] {
|
||||
tartHomeDir = URL(fileURLWithPath: customTartHome)
|
||||
} else {
|
||||
tartHomeDir = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
}
|
||||
|
||||
self.tartHomeDir = tartHomeDir
|
||||
tartCacheDir = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
}
|
||||
|
||||
static func jsonEncoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
|
||||
return encoder
|
||||
}
|
||||
|
||||
static func jsonDecoder() -> JSONDecoder {
|
||||
JSONDecoder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
class DockerConfigCredentialsProvider: CredentialsProvider {
|
||||
func retrieve(host: String) throws -> (String, String)? {
|
||||
let dockerConfigURL = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".docker").appendingPathComponent("config.json")
|
||||
if !FileManager.default.fileExists(atPath: dockerConfigURL.path) {
|
||||
return nil
|
||||
}
|
||||
let config = try JSONDecoder().decode(DockerConfig.self, from: Data(contentsOf: dockerConfigURL))
|
||||
|
||||
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
|
||||
return credentialsFromAuth
|
||||
}
|
||||
if let helperProgram = config.credHelpers?[host] {
|
||||
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func executeHelper(binaryName: String, host: String) throws -> (String, String)? {
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw CredentialsProviderError.Failed(message: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let process = Process.init()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["get"]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let inPipe = Pipe()
|
||||
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = outPipe
|
||||
process.standardInput = inPipe
|
||||
|
||||
process.launch()
|
||||
|
||||
inPipe.fileHandleForWriting.write("\(host)\n".data(using: .utf8)!)
|
||||
inPipe.fileHandleForWriting.closeFile()
|
||||
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helper failed!")
|
||||
}
|
||||
|
||||
let getOutput = try JSONDecoder().decode(
|
||||
DockerGetOutput.self, from: outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
)
|
||||
return (getOutput.Username, getOutput.Secret)
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
throw CredentialsProviderError.Failed(message: "Docker helpers don't support storing!")
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerConfig: Codable {
|
||||
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
|
||||
var credHelpers: Dictionary<String, String>? = Dictionary()
|
||||
}
|
||||
|
||||
struct DockerAuthConfig: Codable {
|
||||
var auth: String? = nil
|
||||
|
||||
func decodeCredentials() -> (String, String)? {
|
||||
// auth is a base64("username:password")
|
||||
guard let authBase64 = auth else {
|
||||
return nil
|
||||
}
|
||||
guard let data = Data(base64Encoded: authBase64) else {
|
||||
return nil
|
||||
}
|
||||
guard let components = String(data: data, encoding: .utf8)?.components(separatedBy: ":") else {
|
||||
return nil
|
||||
}
|
||||
if components.count != 2 {
|
||||
return nil
|
||||
}
|
||||
return (components[0], components[1])
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerGetOutput: Codable {
|
||||
var Username: String
|
||||
var Secret: String
|
||||
}
|
||||
@@ -34,21 +34,37 @@ class KeychainCredentialsProvider: CredentialsProvider {
|
||||
}
|
||||
|
||||
func store(host: String, user: String, password: String) throws {
|
||||
let attributes: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrAccount as String: user,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecValueData as String: password,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
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 = SecItemAdd(attributes as CFDictionary, nil)
|
||||
let status = SecItemCopyMatching(key as CFDictionary, nil)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess, errSecDuplicateItem:
|
||||
return
|
||||
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 returned unsuccessful status \(status)")
|
||||
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OSStatus {
|
||||
func explanation() -> CFString {
|
||||
SecCopyErrorMessageString(self, nil) ?? "Unknown status code \(self)." as CFString
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
class IPSWCache: PrunableStorage {
|
||||
let baseURL: URL
|
||||
|
||||
init() throws {
|
||||
baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func locationFor(image: VZMacOSRestoreImage) -> URL {
|
||||
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
|
||||
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Virtualization
|
||||
|
||||
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||
var terminationReason: Process.TerminationReason
|
||||
var terminationStatus: Int32
|
||||
|
||||
var description: String {
|
||||
var reason: String
|
||||
|
||||
switch terminationReason {
|
||||
case .exit:
|
||||
reason = "exit code \(terminationStatus)"
|
||||
case .uncaughtSignal:
|
||||
reason = "uncaught signal"
|
||||
default:
|
||||
reason = "unknown reason"
|
||||
}
|
||||
|
||||
return "arp command failed: \(reason)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"arp command yielded invalid output: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||
var explanation: String
|
||||
|
||||
var description: String {
|
||||
"ARPCache internal error: \(explanation)"
|
||||
}
|
||||
}
|
||||
|
||||
struct ARPCache {
|
||||
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||
let process = Process.init()
|
||||
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||
process.arguments = ["-an"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
process.standardInput = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||
throw ARPCommandFailedError(
|
||||
terminationReason: process.terminationReason,
|
||||
terminationStatus: process.terminationStatus)
|
||||
}
|
||||
|
||||
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||
}
|
||||
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.components(separatedBy: "\n")
|
||||
|
||||
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||
|
||||
for line in lines {
|
||||
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||
|
||||
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||
}
|
||||
|
||||
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||
guard let ip = IPv4Address(rawIP) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||
}
|
||||
|
||||
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||
if rawMAC == "(incomplete)" {
|
||||
continue
|
||||
}
|
||||
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||
}
|
||||
|
||||
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||
continue
|
||||
}
|
||||
|
||||
if macAddress == mac {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NSTextCheckingResult {
|
||||
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||
let nsRange = self.range(withName: name)
|
||||
|
||||
if nsRange.location == NSNotFound {
|
||||
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||
}
|
||||
|
||||
guard let range = Range.init(nsRange, in: string) else {
|
||||
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||
}
|
||||
|
||||
return String(string[range])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Network
|
||||
|
||||
struct Lease {
|
||||
var mac: MACAddress
|
||||
var ip: IPv4Address
|
||||
|
||||
init?(fromRawLease: [String : String]) {
|
||||
// Retrieve the required fields
|
||||
guard let hwAddress = fromRawLease["hw_address"] else { return nil }
|
||||
guard let ipAddress = fromRawLease["ip_address"] else { return nil }
|
||||
|
||||
// Parse MAC address
|
||||
let hwAddressSplits = hwAddress.split(separator: ",")
|
||||
if hwAddressSplits.count != 2 {
|
||||
return nil
|
||||
}
|
||||
if let hwAddressProto = Int(hwAddressSplits[0]), hwAddressProto != ARPHRD_ETHER {
|
||||
return nil
|
||||
}
|
||||
guard let mac = MACAddress(fromString: String(hwAddressSplits[1])) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse IP address
|
||||
guard let ip = IPv4Address(ipAddress) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.ip = ip
|
||||
self.mac = mac
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
enum LeasesError: Error {
|
||||
case UnexpectedFormat(name: String = "unexpected DHCPD leases file format", message: String, line: Int)
|
||||
case Truncated(name: String = "truncated DHCPD leases file")
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
|
||||
case .UnexpectedFormat(name: let name, message: let message, line: let line):
|
||||
return "\(name) on line \(line): \(message)"
|
||||
case .Truncated(name: let name):
|
||||
return "\(name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Leases {
|
||||
private let leases: [MACAddress : Lease]
|
||||
|
||||
convenience init() throws {
|
||||
try self.init(URL(fileURLWithPath: "/var/db/dhcpd_leases"))
|
||||
}
|
||||
|
||||
convenience init(_ fromURL: URL) throws {
|
||||
let fileContents = try String(contentsOf: fromURL, encoding: .utf8)
|
||||
|
||||
try self.init(fileContents)
|
||||
}
|
||||
|
||||
init(_ fromString: String) throws {
|
||||
var leases: [MACAddress : Lease] = Dictionary()
|
||||
|
||||
for lease in try Self.retrieveRawLeases(fromString).compactMap({ Lease(fromRawLease: $0) }) {
|
||||
leases[lease.mac] = lease
|
||||
}
|
||||
|
||||
self.leases = leases
|
||||
}
|
||||
|
||||
/// Parse leases from the host cache similarly to the PLCache_read() function found in Apple's Open Source releases.
|
||||
///
|
||||
/// [1]: https://github.com/apple-opensource/bootp/blob/master/bootplib/NICache.c#L285-L391
|
||||
private static func retrieveRawLeases(_ dhcpdLeasesContents: String) throws -> [[String : String]] {
|
||||
var rawLeases: [[String : String]] = Array()
|
||||
|
||||
enum State {
|
||||
case Nowhere
|
||||
case Start
|
||||
case Body
|
||||
case End
|
||||
}
|
||||
var state = State.Nowhere
|
||||
|
||||
var currentRawLease: [String : String] = Dictionary()
|
||||
|
||||
for (lineNumber, line) in dhcpdLeasesContents.split(separator: "\n").enumerated().map({ ($0 + 1, $1) }) {
|
||||
if line == "{" {
|
||||
// Handle lease block start
|
||||
if state != .Nowhere && state != .End {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block start ({)", line: lineNumber)
|
||||
}
|
||||
|
||||
state = .Start
|
||||
} else if line == "}" {
|
||||
// Handle lease block end
|
||||
if state != .Body {
|
||||
throw LeasesError.UnexpectedFormat(message: "unexpected lease block end (})", line: lineNumber)
|
||||
}
|
||||
|
||||
rawLeases.append(currentRawLease)
|
||||
currentRawLease = Dictionary()
|
||||
|
||||
state = .End
|
||||
} else {
|
||||
// Handle lease block contents
|
||||
let lineWithoutTabs = String(line.drop { $0 == " " || $0 == "\t"})
|
||||
|
||||
if lineWithoutTabs.isEmpty {
|
||||
continue
|
||||
}
|
||||
|
||||
let splits = lineWithoutTabs.split(separator: "=", maxSplits: 1)
|
||||
if splits.count != 2 {
|
||||
throw LeasesError.UnexpectedFormat(message: "key-value pair with only a key", line: lineNumber)
|
||||
}
|
||||
let (key, value) = (String(splits[0]), String(splits[1]))
|
||||
|
||||
currentRawLease[key] = value
|
||||
|
||||
state = .Body
|
||||
}
|
||||
}
|
||||
|
||||
if state == .Start || state == .Body {
|
||||
throw LeasesError.Truncated()
|
||||
}
|
||||
|
||||
return rawLeases
|
||||
}
|
||||
|
||||
func resolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? {
|
||||
leases[macAddress]?.ip
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct MACAddress: Equatable, CustomStringConvertible {
|
||||
struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
||||
var mac: [UInt8] = Array(repeating: 0, count: 6)
|
||||
|
||||
init?(fromString: String) {
|
||||
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, CustomStringConvertible {
|
||||
}
|
||||
|
||||
var description: String {
|
||||
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
protocol Authentication {
|
||||
func header() -> (String, String)
|
||||
func isValid() -> Bool
|
||||
}
|
||||
|
||||
struct BasicAuthentication: Authentication {
|
||||
let user: String
|
||||
let password: String
|
||||
|
||||
func header() -> (String, String) {
|
||||
let creds = Data("\(user):\(password)".utf8).base64EncodedString()
|
||||
|
||||
return ("Authorization", "Basic \(creds)")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,54 @@ import Foundation
|
||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
// Annotations
|
||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||
|
||||
struct OCIManifest: Codable, Equatable {
|
||||
var schemaVersion: Int = 2
|
||||
var mediaType: String = ociManifestMediaType
|
||||
var config: OCIManifestConfig
|
||||
var layers: [OCIManifestLayer] = Array()
|
||||
var annotations: Dictionary<String, String>?
|
||||
|
||||
init(config: OCIManifestConfig, layers: [OCIManifestLayer], uncompressedDiskSize: UInt64? = nil) {
|
||||
self.config = config
|
||||
self.layers = layers
|
||||
|
||||
if let uncompressedDiskSize = uncompressedDiskSize {
|
||||
annotations = [
|
||||
uncompressedDiskSizeAnnotation: String(uncompressedDiskSize)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func digest() throws -> String {
|
||||
try Digest.hash(JSONEncoder().encode(self))
|
||||
try Digest.hash(toJSON())
|
||||
}
|
||||
|
||||
func uncompressedDiskSize() -> UInt64? {
|
||||
guard let value = annotations?[uncompressedDiskSizeAnnotation] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return UInt64(value)
|
||||
}
|
||||
}
|
||||
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: Architecture = .arm64
|
||||
var os: OS = .darwin
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+112
-56
@@ -2,6 +2,8 @@ import Foundation
|
||||
import NIOCore
|
||||
import NIOHTTP1
|
||||
import AsyncHTTPClient
|
||||
import Algorithms
|
||||
import NIOPosix
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: UInt, details: String = "")
|
||||
@@ -25,7 +27,7 @@ extension HTTPClientResponse.Body {
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
struct TokenResponse: Decodable, Authentication {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
@@ -34,19 +36,20 @@ struct TokenResponse: Decodable {
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = JSONDecoder()
|
||||
let decoder = Config.jsonDecoder()
|
||||
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
|
||||
// RFC3339 date formatter from Apple's documentation[1]
|
||||
//
|
||||
// [1]: https://developer.apple.com/documentation/foundation/dateformatter
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let dateString = try container.decode(String.self)
|
||||
|
||||
return dateFormatter.date(from: dateString) ?? Date()
|
||||
}
|
||||
|
||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||
}
|
||||
@@ -65,47 +68,49 @@ struct TokenResponse: Decodable {
|
||||
}
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
get {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
func header() -> (String, String) {
|
||||
("Authorization", "Bearer \(token)")
|
||||
}
|
||||
|
||||
func isValid() -> Bool {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
}
|
||||
|
||||
class Registry {
|
||||
private let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
|
||||
|
||||
private let httpClient = HTTPClient(
|
||||
eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 1))
|
||||
)
|
||||
|
||||
deinit {
|
||||
try! httpClient.syncShutdown()
|
||||
}
|
||||
|
||||
|
||||
let baseURL: URL
|
||||
let namespace: String
|
||||
let credentialsProvider: CredentialsProvider
|
||||
let credentialsProviders: [CredentialsProvider]
|
||||
|
||||
var currentAuthToken: TokenResponse? = nil
|
||||
var currentAuthToken: Authentication? = nil
|
||||
|
||||
init(urlComponents: URLComponents,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
baseURL = urlComponents.url!
|
||||
self.namespace = namespace
|
||||
self.credentialsProvider = credentialsProvider
|
||||
self.credentialsProviders = credentialsProviders
|
||||
}
|
||||
|
||||
convenience init(
|
||||
host: String,
|
||||
namespace: String,
|
||||
credentialsProvider: CredentialsProvider = KeychainCredentialsProvider()
|
||||
host: String,
|
||||
namespace: String,
|
||||
insecure: Bool = false,
|
||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||
) throws {
|
||||
var baseURLComponents = URLComponents()
|
||||
let proto = insecure ? "http" : "https"
|
||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||
|
||||
baseURLComponents.scheme = "https"
|
||||
baseURLComponents.host = host
|
||||
baseURLComponents.path = "/v2/"
|
||||
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProvider: credentialsProvider)
|
||||
try self.init(urlComponents: baseURLComponents, namespace: namespace, credentialsProviders: credentialsProviders)
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
@@ -116,7 +121,7 @@ class Registry {
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, manifest: OCIManifest) async throws -> String {
|
||||
let manifestJSON = try JSONEncoder().encode(manifest)
|
||||
let manifestJSON = try manifest.toJSON()
|
||||
|
||||
let response = try await endpointRequest(.PUT, "\(namespace)/manifests/\(reference)",
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
@@ -139,7 +144,7 @@ class Registry {
|
||||
}
|
||||
|
||||
let manifestData = try await response.body.readResponse()
|
||||
let manifest = try JSONDecoder().decode(OCIManifest.self, from: manifestData)
|
||||
let manifest = try OCIManifest(fromJSON: manifestData)
|
||||
|
||||
return (manifest, manifestData)
|
||||
}
|
||||
@@ -156,7 +161,7 @@ class Registry {
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSize: Int = 5 * 1024 * 1024) async throws -> String {
|
||||
public func pushBlob(fromData: Data, chunkSizeMb: Int = 0) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let postResponse = try await endpointRequest(.POST, "\(namespace)/blobs/uploads/",
|
||||
headers: ["Content-Length": "0"])
|
||||
@@ -167,27 +172,55 @@ class Registry {
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
let uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
// Upload the blob
|
||||
let headers = [
|
||||
"Content-Length": "\(fromData.count)",
|
||||
"Content-Type": "application/octet-stream",
|
||||
]
|
||||
var uploadLocation = try uploadLocationFromResponse(postResponse)
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let parameters = [
|
||||
"digest": digest,
|
||||
]
|
||||
|
||||
let putResponse = try await rawRequest(.PUT, uploadLocation, headers: headers, parameters: parameters,
|
||||
body: fromData)
|
||||
if putResponse.status != .created {
|
||||
let body = try await postResponse.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT)", code: putResponse.status.code,
|
||||
details: body ?? "")
|
||||
|
||||
if chunkSizeMb == 0 {
|
||||
// monolithic upload
|
||||
let response = try await rawRequest(
|
||||
.PUT,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
],
|
||||
parameters: ["digest": digest],
|
||||
body: fromData
|
||||
)
|
||||
if response.status != .created {
|
||||
let body = try await response.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT) to \(uploadLocation)",
|
||||
code: response.status.code, details: body ?? "")
|
||||
}
|
||||
return digest
|
||||
}
|
||||
|
||||
// chunked upload
|
||||
var uploadedBytes = 0
|
||||
let chunks = fromData.chunks(ofCount: chunkSizeMb == 0 ? fromData.count : chunkSizeMb * 1_000_000)
|
||||
for (index, chunk) in chunks.enumerated() {
|
||||
let lastChunk = index == (chunks.count - 1)
|
||||
let response = try await rawRequest(
|
||||
lastChunk ? .PUT : .PATCH,
|
||||
uploadLocation,
|
||||
headers: [
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Range": "\(uploadedBytes)-\(uploadedBytes + chunk.count - 1)",
|
||||
],
|
||||
parameters: lastChunk ? ["digest": digest] : [:],
|
||||
body: chunk
|
||||
)
|
||||
let expectedStatus: HTTPResponseStatus = lastChunk ? .created : .accepted
|
||||
if response.status != expectedStatus {
|
||||
let body = try await response.body.readTextResponse()
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||
code: response.status.code, details: body ?? "")
|
||||
}
|
||||
uploadedBytes += chunk.count
|
||||
// Update location for the next chunk
|
||||
uploadLocation = try uploadLocationFromResponse(response)
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
@@ -200,6 +233,8 @@ class Registry {
|
||||
}
|
||||
|
||||
for try await part in response.body {
|
||||
try Task.checkCancellation()
|
||||
|
||||
try handler(part)
|
||||
}
|
||||
}
|
||||
@@ -222,11 +257,12 @@ class Registry {
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil
|
||||
body: Data? = nil,
|
||||
doAuth: Bool = true
|
||||
) async throws -> HTTPClientResponse {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil {
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
urlComponents.queryItems = []
|
||||
}
|
||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
||||
@@ -239,17 +275,18 @@ class Registry {
|
||||
request.headers.add(name: key, value: value)
|
||||
}
|
||||
if body != nil {
|
||||
request.headers.add(name: "Content-Length", value: "\(body!.count)")
|
||||
request.body = HTTPClientRequest.Body.bytes(body!)
|
||||
}
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid == false {
|
||||
if currentAuthToken?.isValid() == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var response = try await authAwareRequest(request: request)
|
||||
|
||||
if response.status == .unauthorized {
|
||||
if doAuth && response.status == .unauthorized {
|
||||
try await auth(response: response)
|
||||
response = try await authAwareRequest(request: request)
|
||||
}
|
||||
@@ -264,6 +301,15 @@ class Registry {
|
||||
}
|
||||
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
|
||||
if wwwAuthenticate.scheme == "Basic" {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
currentAuthToken = BasicAuthentication(user: user, password: password)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if wwwAuthenticate.scheme != "Bearer" {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||
@@ -294,12 +340,12 @@ class Registry {
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try credentialsProvider.retrieve(host: baseURL.host!) {
|
||||
if let (user, password) = try lookupCredentials(host: baseURL.host!) {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
|
||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers)
|
||||
let response = try await rawRequest(.GET, authenticateURL, headers: headers, doAuth: false)
|
||||
if response.status != .ok {
|
||||
let body = try await response.body.readTextResponse() ?? ""
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.status.code) "
|
||||
@@ -310,11 +356,21 @@ class Registry {
|
||||
currentAuthToken = try TokenResponse.parse(fromData: bodyData)
|
||||
}
|
||||
|
||||
private func lookupCredentials(host: String) throws -> (String, String)? {
|
||||
for provider in credentialsProviders {
|
||||
if let (user, password) = try provider.retrieve(host: host) {
|
||||
return (user, password)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: HTTPClientRequest) async throws -> HTTPClientResponse {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
request.headers.add(name: "Authorization", value: "Bearer \(token.token)")
|
||||
let (name, value) = token.header()
|
||||
request.headers.add(name: name, value: value)
|
||||
}
|
||||
|
||||
return try await httpClient.execute(request, deadline: .distantFuture)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct PassphraseGenerator: Sequence {
|
||||
func makeIterator() -> PassphraseIterator {
|
||||
PassphraseIterator()
|
||||
}
|
||||
}
|
||||
|
||||
struct PassphraseIterator: IteratorProtocol {
|
||||
mutating func next() -> String? {
|
||||
passphrases[Int(arc4random_uniform(UInt32(passphrases.count)))]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum Architecture: String, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
|
||||
func CurrentArchitecture() -> Architecture {
|
||||
#if arch(arm64)
|
||||
return .arm64
|
||||
#elseif arch(x86_64)
|
||||
return .amd64
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Virtualization
|
||||
|
||||
struct Darwin: Platform {
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
|
||||
init(ecid: VZMacMachineIdentifier, hardwareModel: VZMacHardwareModel) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let encodedECID = try container.decode(String.self, forKey: .ecid)
|
||||
guard let data = Data.init(base64Encoded: encodedECID) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize Data using the provided value")
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
}
|
||||
|
||||
func os() -> OS {
|
||||
.darwin
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
VZMacOSBootLoader()
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
let result = VZMacPlatformConfiguration()
|
||||
|
||||
result.machineIdentifier = ecid
|
||||
result.auxiliaryStorage = VZMacAuxiliaryStorage(contentsOf: nvramURL)
|
||||
result.hardwareModel = hardwareModel
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZMacGraphicsDeviceConfiguration()
|
||||
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(width: vmConfig.display.width, height: vmConfig.display.height)
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
result.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// A reasonable guess according to Apple's documentation[1]
|
||||
// [1]: https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
if #available(macOS 13, *) {
|
||||
// Trackpad is only supported starting with macOS Ventura
|
||||
// macOS Monterey will continue using a USB device == .darwin
|
||||
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
} else {
|
||||
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Virtualization
|
||||
|
||||
@available(macOS 13, *)
|
||||
struct Linux: Platform {
|
||||
func os() -> OS {
|
||||
.linux
|
||||
}
|
||||
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader {
|
||||
let result = VZEFIBootLoader()
|
||||
|
||||
result.variableStore = VZEFIVariableStore(url: nvramURL)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration {
|
||||
VZGenericPlatformConfiguration()
|
||||
}
|
||||
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration {
|
||||
let result = VZVirtioGraphicsDeviceConfiguration()
|
||||
|
||||
result.scanouts = [
|
||||
VZVirtioGraphicsScanoutConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height
|
||||
)
|
||||
]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration] {
|
||||
[VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Virtualization
|
||||
|
||||
enum OS: String, Codable {
|
||||
case darwin
|
||||
case linux
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Virtualization
|
||||
|
||||
protocol Platform: Codable {
|
||||
func os() -> OS
|
||||
func bootLoader(nvramURL: URL) throws -> VZBootLoader
|
||||
func platform(nvramURL: URL) -> VZPlatformConfiguration
|
||||
func graphicsDevice(vmConfig: VMConfig) -> VZGraphicsDeviceConfiguration
|
||||
func pointingDevices() -> [VZPointingDeviceConfiguration]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
protocol PrunableStorage {
|
||||
func prunables() throws -> [Prunable]
|
||||
}
|
||||
|
||||
protocol Prunable {
|
||||
func delete() throws
|
||||
func accessDate() throws -> Date
|
||||
func sizeBytes() throws -> Int
|
||||
}
|
||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
||||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Prune.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
@@ -28,7 +29,6 @@ struct Root: AsyncParsableCommand {
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
Darwin.exit(1)
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
enum SoftnetError: Error {
|
||||
case InitializationFailed(why: String)
|
||||
}
|
||||
|
||||
class Softnet {
|
||||
private let process = Process()
|
||||
|
||||
let vmFD: Int32
|
||||
|
||||
init(vmMACAddress: String) throws {
|
||||
let binaryName = "softnet"
|
||||
|
||||
guard let executableURL = resolveBinaryPath(binaryName) else {
|
||||
throw SoftnetError.InitializationFailed(why: "\(binaryName) not found in PATH")
|
||||
}
|
||||
|
||||
let fds = UnsafeMutablePointer<Int32>.allocate(capacity: MemoryLayout<Int>.stride * 2)
|
||||
|
||||
let ret = socketpair(AF_UNIX, SOCK_DGRAM, 0, fds)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "socketpair() failed with exit code \(ret)")
|
||||
}
|
||||
|
||||
vmFD = fds[0]
|
||||
let softnetFD = fds[1]
|
||||
|
||||
try setSocketBuffers(vmFD, 1 * 1024 * 1024);
|
||||
try setSocketBuffers(softnetFD, 1 * 1024 * 1024);
|
||||
|
||||
process.executableURL = executableURL
|
||||
process.arguments = ["--vm-fd", String(STDIN_FILENO), "--vm-mac-address", vmMACAddress]
|
||||
process.standardInput = FileHandle(fileDescriptor: softnetFD, closeOnDealloc: false)
|
||||
}
|
||||
|
||||
func run() throws {
|
||||
try process.run()
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
process.interrupt()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
|
||||
private func setSocketBuffers(_ fd: Int32, _ sizeBytes: Int) throws {
|
||||
var option_value = sizeBytes
|
||||
let option_len = socklen_t(MemoryLayout<Int>.size)
|
||||
|
||||
var ret = setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &option_value, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_RCVBUF) returned \(ret)")
|
||||
}
|
||||
|
||||
ret = setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &option_value, option_len)
|
||||
if ret != 0 {
|
||||
throw SoftnetError.InitializationFailed(why: "setsockopt(SO_SNDBUF) returned \(ret)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func accessDate() throws -> Date {
|
||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||
return attrs.contentAccessDate!
|
||||
}
|
||||
|
||||
func updateAccessDate(_ accessDate: Date = Date()) throws {
|
||||
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
|
||||
let modificationDate = attrs.contentAccessDate!
|
||||
|
||||
let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
|
||||
let ret = utimes(path, times)
|
||||
if ret != 0 {
|
||||
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
func asTimeval() -> timeval {
|
||||
timeval(tv_sec: Int(timeIntervalSince1970), tv_usec: 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
extension URL: Prunable {
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,20 @@ extension Collection {
|
||||
indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveBinaryPath(_ name: String) -> URL? {
|
||||
guard let path = ProcessInfo.processInfo.environment["PATH"] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for pathComponent in path.split(separator: ":") {
|
||||
let url = URL(fileURLWithPath: String(pathComponent))
|
||||
.appendingPathComponent(name, isDirectory: false)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Dynamic
|
||||
// Kudos to @saagarjha's VirtualApple for finding about _VZVirtualMachineStartOptions
|
||||
|
||||
extension VZVirtualMachine {
|
||||
@available(macOS 12, *)
|
||||
func start(_ recovery: Bool) async throws {
|
||||
if !recovery {
|
||||
// just use the regular API
|
||||
|
||||
+141
-50
@@ -10,6 +10,17 @@ struct NoMainScreenFoundError: Error {
|
||||
struct DownloadFailed: Error {
|
||||
}
|
||||
|
||||
struct UnsupportedOSError: Error, CustomStringConvertible {
|
||||
let description: String
|
||||
|
||||
init(_ what: String, _ plural: String) {
|
||||
description = "error: \(what) \(plural) only supported on macOS 13.0 (Ventura) or newer"
|
||||
}
|
||||
}
|
||||
|
||||
struct UnsupportedArchitectureError: Error {
|
||||
}
|
||||
|
||||
class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// Virtualization.Framework's virtual machine
|
||||
@Published var virtualMachine: VZVirtualMachine
|
||||
@@ -23,17 +34,32 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
// VM's config
|
||||
var config: VMConfig
|
||||
|
||||
init(vmDir: VMDirectory) throws {
|
||||
let auxStorage = VZMacAuxiliaryStorage(contentsOf: vmDir.nvramURL)
|
||||
var softnet: Softnet? = nil
|
||||
|
||||
init(vmDir: VMDirectory,
|
||||
withSoftnet: Bool = false,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = [],
|
||||
directoryShares: [DirectoryShare] = []
|
||||
) throws {
|
||||
name = vmDir.name
|
||||
config = try VMConfig.init(fromURL: vmDir.configURL)
|
||||
|
||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
||||
if config.arch != CurrentArchitecture() {
|
||||
throw UnsupportedArchitectureError()
|
||||
}
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL,
|
||||
nvramURL: vmDir.nvramURL, vmConfig: config,
|
||||
softnet: softnet, additionalDiskAttachments: additionalDiskAttachments,
|
||||
directoryShares: directoryShares)
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
|
||||
virtualMachine.delegate = self
|
||||
}
|
||||
|
||||
@@ -45,14 +71,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let ipswCacheFolder = Config.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)
|
||||
|
||||
let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
|
||||
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
||||
|
||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||
try expectedIPSWLocation.updateAccessDate()
|
||||
return expectedIPSWLocation
|
||||
}
|
||||
|
||||
@@ -77,8 +100,23 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
try data.write(to: expectedIPSWLocation, options: [.atomic])
|
||||
return expectedIPSWLocation
|
||||
}
|
||||
|
||||
var inFinalState: Bool {
|
||||
get {
|
||||
virtualMachine.state == VZVirtualMachine.State.stopped ||
|
||||
virtualMachine.state == VZVirtualMachine.State.paused ||
|
||||
virtualMachine.state == VZVirtualMachine.State.error
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
init(vmDir: VMDirectory, ipswURL: URL?, diskSizeGB: UInt8) async throws {
|
||||
init(
|
||||
vmDir: VMDirectory,
|
||||
ipswURL: URL?,
|
||||
diskSizeGB: UInt16,
|
||||
withSoftnet: Bool = false,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment] = []
|
||||
) async throws {
|
||||
let ipswURL = ipswURL != nil ? ipswURL! : try await VM.retrieveLatestIPSW();
|
||||
|
||||
// Load the restore image and try to get the requirements
|
||||
@@ -94,7 +132,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
// Create NVRAM
|
||||
let auxStorage = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
@@ -102,7 +140,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
name = vmDir.name
|
||||
// Create config
|
||||
config = VMConfig(
|
||||
hardwareModel: requirements.hardwareModel,
|
||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
@@ -111,11 +149,17 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
let configuration = try VM.craftConfiguration(diskURL: vmDir.diskURL, auxStorage: auxStorage, vmConfig: config)
|
||||
if withSoftnet {
|
||||
softnet = try Softnet(vmMACAddress: config.macAddress.string)
|
||||
}
|
||||
|
||||
let configuration = try Self.craftConfiguration(diskURL: vmDir.diskURL, nvramURL: vmDir.nvramURL,
|
||||
vmConfig: config, softnet: softnet,
|
||||
additionalDiskAttachments: additionalDiskAttachments,
|
||||
directoryShares: [])
|
||||
virtualMachine = VZVirtualMachine(configuration: configuration)
|
||||
|
||||
super.init()
|
||||
|
||||
virtualMachine.delegate = self
|
||||
|
||||
// Run automated installation
|
||||
@@ -133,8 +177,39 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 13, *)
|
||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
||||
// Create NVRAM
|
||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||
|
||||
// Create disk
|
||||
try vmDir.resizeDisk(diskSizeGB)
|
||||
|
||||
// Create config
|
||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
return try VM(vmDir: vmDir)
|
||||
}
|
||||
|
||||
func run(_ recovery: Bool) async throws {
|
||||
try await virtualMachine.start(recovery)
|
||||
if let softnet = softnet {
|
||||
try softnet.run()
|
||||
}
|
||||
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
if #available(macOS 13, *) {
|
||||
// new API introduced in Ventura
|
||||
let startOptions = VZMacOSVirtualMachineStartOptions()
|
||||
startOptions.startUpFromMacOSRecovery = recovery
|
||||
try await virtualMachine.start(options: startOptions)
|
||||
} else {
|
||||
// use method that also available on Monterey
|
||||
try await virtualMachine.start(recovery)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await withTaskCancellationHandler(operation: {
|
||||
sema.wait()
|
||||
@@ -149,72 +224,82 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let softnet = softnet {
|
||||
try softnet.stop();
|
||||
}
|
||||
}
|
||||
|
||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
||||
static func craftConfiguration(
|
||||
diskURL: URL,
|
||||
nvramURL: URL,
|
||||
vmConfig: VMConfig,
|
||||
softnet: Softnet? = nil,
|
||||
additionalDiskAttachments: [VZDiskImageStorageDeviceAttachment],
|
||||
directoryShares: [DirectoryShare]
|
||||
) throws -> VZVirtualMachineConfiguration {
|
||||
let configuration = VZVirtualMachineConfiguration()
|
||||
|
||||
// Boot loader
|
||||
configuration.bootLoader = VZMacOSBootLoader()
|
||||
configuration.bootLoader = try vmConfig.platform.bootLoader(nvramURL: nvramURL)
|
||||
|
||||
// CPU and memory
|
||||
configuration.cpuCount = vmConfig.cpuCount
|
||||
configuration.memorySize = vmConfig.memorySize
|
||||
|
||||
// Platform
|
||||
let platform = VZMacPlatformConfiguration()
|
||||
|
||||
platform.machineIdentifier = vmConfig.ecid
|
||||
platform.auxiliaryStorage = auxStorage
|
||||
platform.hardwareModel = vmConfig.hardwareModel
|
||||
|
||||
configuration.platform = platform
|
||||
configuration.platform = vmConfig.platform.platform(nvramURL: nvramURL)
|
||||
|
||||
// Display
|
||||
let graphicsDeviceConfiguration = VZMacGraphicsDeviceConfiguration()
|
||||
if let hostMainScreen = NSScreen.main {
|
||||
let vmScreenSize = NSSize(
|
||||
width: vmConfig.display.width,
|
||||
height: vmConfig.display.height
|
||||
)
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(for: hostMainScreen, sizeInPoints: vmScreenSize)
|
||||
]
|
||||
} else {
|
||||
graphicsDeviceConfiguration.displays = [
|
||||
VZMacGraphicsDisplayConfiguration(
|
||||
widthInPixels: vmConfig.display.width,
|
||||
heightInPixels: vmConfig.display.height,
|
||||
// Reasonable guess like https://developer.apple.com/documentation/coregraphics/1456599-cgdisplayscreensize
|
||||
pixelsPerInch: 72
|
||||
)
|
||||
]
|
||||
}
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
configuration.graphicsDevices = [vmConfig.platform.graphicsDevice(vmConfig: vmConfig)]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||
let inputAudioStreamConfiguration = VZVirtioSoundDeviceInputStreamConfiguration()
|
||||
inputAudioStreamConfiguration.source = VZHostAudioInputStreamSource()
|
||||
let outputAudioStreamConfiguration = VZVirtioSoundDeviceOutputStreamConfiguration()
|
||||
outputAudioStreamConfiguration.sink = VZHostAudioOutputStreamSink()
|
||||
soundDeviceConfiguration.streams = [inputAudioStreamConfiguration, outputAudioStreamConfiguration]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
configuration.pointingDevices = vmConfig.platform.pointingDevices()
|
||||
|
||||
// Networking
|
||||
let vio = VZVirtioNetworkDeviceConfiguration()
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
|
||||
if let softnet = softnet {
|
||||
let fh = FileHandle.init(fileDescriptor: softnet.vmFD)
|
||||
vio.attachment = VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
|
||||
} else {
|
||||
vio.attachment = VZNATNetworkDeviceAttachment()
|
||||
}
|
||||
vio.macAddress = vmConfig.macAddress
|
||||
configuration.networkDevices = [vio]
|
||||
|
||||
// Storage
|
||||
let attachment = try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)
|
||||
let storage = VZVirtioBlockDeviceConfiguration(attachment: attachment)
|
||||
configuration.storageDevices = [storage]
|
||||
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
|
||||
attachments.append(contentsOf: additionalDiskAttachments)
|
||||
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
|
||||
|
||||
// Entropy
|
||||
configuration.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
|
||||
// Directory share
|
||||
if #available(macOS 13, *) {
|
||||
var directories: [String : VZSharedDirectory] = Dictionary()
|
||||
directoryShares.forEach { directories[$0.name] = VZSharedDirectory(url: $0.path, readOnly: $0.readOnly) }
|
||||
|
||||
let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag
|
||||
let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag)
|
||||
sharingDevice.share = VZMultipleDirectoryShare(directories: directories)
|
||||
|
||||
configuration.directorySharingDevices = [sharingDevice]
|
||||
} else if !directoryShares.isEmpty {
|
||||
throw UnsupportedOSError("directory sharing", "is")
|
||||
}
|
||||
|
||||
try configuration.validate()
|
||||
|
||||
return configuration
|
||||
@@ -235,3 +320,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
sema.signal()
|
||||
}
|
||||
}
|
||||
|
||||
struct DirectoryShare {
|
||||
let name: String
|
||||
let path: URL
|
||||
let readOnly: Bool
|
||||
}
|
||||
|
||||
+37
-39
@@ -16,14 +16,18 @@ class LessThanMinimalResourcesError: NSObject, LocalizedError {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case version
|
||||
case ecid
|
||||
case hardwareModel
|
||||
case os
|
||||
case arch
|
||||
case cpuCountMin
|
||||
case cpuCount
|
||||
case memorySizeMin
|
||||
case memorySize
|
||||
case macAddress
|
||||
case display
|
||||
|
||||
// macOS-specific keys
|
||||
case ecid
|
||||
case hardwareModel
|
||||
}
|
||||
|
||||
struct VMDisplayConfig: Codable {
|
||||
@@ -33,25 +37,25 @@ struct VMDisplayConfig: Codable {
|
||||
|
||||
struct VMConfig: Codable {
|
||||
var version: Int = 1
|
||||
var ecid: VZMacMachineIdentifier
|
||||
var hardwareModel: VZMacHardwareModel
|
||||
var os: OS
|
||||
var arch: Architecture
|
||||
var platform: Platform
|
||||
var cpuCountMin: Int
|
||||
private(set) var cpuCount: Int
|
||||
var memorySizeMin: UInt64
|
||||
private(set) var memorySize: UInt64
|
||||
var macAddress: VZMACAddress
|
||||
|
||||
var display: VMDisplayConfig = VMDisplayConfig()
|
||||
|
||||
init(
|
||||
ecid: VZMacMachineIdentifier = VZMacMachineIdentifier(),
|
||||
hardwareModel: VZMacHardwareModel,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
platform: Platform,
|
||||
cpuCountMin: Int,
|
||||
memorySizeMin: UInt64,
|
||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
||||
) {
|
||||
self.ecid = ecid
|
||||
self.hardwareModel = hardwareModel
|
||||
self.os = platform.os()
|
||||
self.arch = CurrentArchitecture()
|
||||
self.platform = platform
|
||||
self.macAddress = macAddress
|
||||
self.cpuCountMin = cpuCountMin
|
||||
self.memorySizeMin = memorySizeMin
|
||||
@@ -59,12 +63,16 @@ struct VMConfig: Codable {
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
|
||||
init(fromData: Data) throws {
|
||||
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
|
||||
init(fromJSON: Data) throws {
|
||||
self = try Config.jsonDecoder().decode(Self.self, from: fromJSON)
|
||||
}
|
||||
|
||||
init(fromURL: URL) throws {
|
||||
self = try Self(fromData: try Data(contentsOf: fromURL))
|
||||
self = try Self(fromJSON: try Data(contentsOf: fromURL))
|
||||
}
|
||||
|
||||
func toJSON() throws -> Data {
|
||||
try Config.jsonEncoder().encode(self)
|
||||
}
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
@@ -77,29 +85,18 @@ struct VMConfig: Codable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
version = try container.decode(Int.self, forKey: .version)
|
||||
|
||||
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")
|
||||
os = try container.decodeIfPresent(OS.self, forKey: .os) ?? .darwin
|
||||
arch = try container.decodeIfPresent(Architecture.self, forKey: .arch) ?? .arm64
|
||||
switch os {
|
||||
case .darwin:
|
||||
platform = try Darwin(from: decoder)
|
||||
case .linux:
|
||||
if #available(macOS 13, *) {
|
||||
platform = try Linux(from: decoder)
|
||||
} else {
|
||||
throw UnsupportedOSError("Linux VMs", "are")
|
||||
}
|
||||
}
|
||||
guard let ecid = VZMacMachineIdentifier.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .ecid,
|
||||
in: container,
|
||||
debugDescription: "failed to initialize VZMacMachineIdentifier using the provided value")
|
||||
}
|
||||
self.ecid = ecid
|
||||
|
||||
let encodedHardwareModel = try container.decode(String.self, forKey: .hardwareModel)
|
||||
guard let data = Data.init(base64Encoded: encodedHardwareModel) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
guard let hardwareModel = VZMacHardwareModel.init(dataRepresentation: data) else {
|
||||
throw DecodingError.dataCorruptedError(forKey: .hardwareModel, in: container, debugDescription: "")
|
||||
}
|
||||
self.hardwareModel = hardwareModel
|
||||
|
||||
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
|
||||
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
|
||||
memorySizeMin = try container.decode(UInt64.self, forKey: .memorySizeMin)
|
||||
@@ -121,8 +118,9 @@ struct VMConfig: Codable {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(version, forKey: .version)
|
||||
try container.encode(ecid.dataRepresentation.base64EncodedString(), forKey: .ecid)
|
||||
try container.encode(hardwareModel.dataRepresentation.base64EncodedString(), forKey: .hardwareModel)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(arch, forKey: .arch)
|
||||
try platform.encode(to: encoder)
|
||||
try container.encode(cpuCountMin, forKey: .cpuCountMin)
|
||||
try container.encode(cpuCount, forKey: .cpuCount)
|
||||
try container.encode(memorySizeMin, forKey: .memorySizeMin)
|
||||
|
||||
@@ -98,13 +98,14 @@ extension VMDirectory {
|
||||
try nvram.close()
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String]) async throws -> RemoteName {
|
||||
func pushToRegistry(registry: Registry, references: [String], chunkSizeMb: Int) async throws -> RemoteName {
|
||||
var layers = Array<OCIManifestLayer>()
|
||||
|
||||
// Read VM's config and push it as blob
|
||||
let config = try VMConfig(fromURL: configURL)
|
||||
let configJSON = try JSONEncoder().encode(config)
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON)
|
||||
defaultLogger.appendNewLine("pushing config...")
|
||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
||||
|
||||
// Progress
|
||||
@@ -116,37 +117,37 @@ extension VMDirectory {
|
||||
|
||||
// Read VM's compressed disk as chunks
|
||||
// and sequentially upload them as blobs
|
||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
||||
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||
let mappedDiskSize = mappedDisk.count
|
||||
var mappedDiskReadOffset = 0
|
||||
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||
mappedDiskReadOffset += bytesRead
|
||||
|
||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
||||
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||
|
||||
return data
|
||||
}
|
||||
while let chunk = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let chunkDigest = try await registry.pushBlob(fromData: chunk)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: chunk.count, digest: chunkDigest))
|
||||
while let compressedLayerData = try compressingFilter.readData(ofLength: Self.layerLimitBytes) {
|
||||
let layerDigest = try await registry.pushBlob(fromData: compressedLayerData, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.diskMediaType, size: compressedLayerData.count, digest: layerDigest))
|
||||
}
|
||||
|
||||
// Read VM's NVRAM and push it as blob
|
||||
defaultLogger.appendNewLine("pushing NVRAM...")
|
||||
|
||||
let nvram = try FileHandle(forReadingFrom: nvramURL).readToEnd()!
|
||||
let nvramDigest = try await registry.pushBlob(fromData: nvram)
|
||||
let nvramDigest = try await registry.pushBlob(fromData: nvram, chunkSizeMb: chunkSizeMb)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
|
||||
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
||||
let ociConfigJSON = try OCIConfig(architecture: config.arch, os: config.os).toJSON()
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON, chunkSizeMb: chunkSizeMb)
|
||||
let manifest = OCIManifest(
|
||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||
layers: layers
|
||||
layers: layers,
|
||||
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||
)
|
||||
|
||||
// Manifest
|
||||
|
||||
@@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
|
||||
struct AlreadyInitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct VMDirectory {
|
||||
struct VMDirectory: Prunable {
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -20,6 +20,10 @@ struct VMDirectory {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
|
||||
var explicitlyPulledMark: URL {
|
||||
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||
}
|
||||
|
||||
var name: String {
|
||||
baseURL.lastPathComponent
|
||||
}
|
||||
@@ -68,7 +72,7 @@ struct VMDirectory {
|
||||
try newVMConfig.save(toURL: to.configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt8) throws {
|
||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
}
|
||||
@@ -77,4 +81,24 @@ struct VMDirectory {
|
||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||
try diskFileHandle.close()
|
||||
}
|
||||
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
|
||||
func accessDate() throws -> Date {
|
||||
try baseURL.accessDate()
|
||||
}
|
||||
|
||||
func sizeBytes() throws -> Int {
|
||||
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
|
||||
}
|
||||
|
||||
func markExplicitlyPulled() {
|
||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||
}
|
||||
|
||||
func isExplicitlyPulled() -> Bool {
|
||||
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
let baseURL: URL = Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageOCI {
|
||||
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
class VMStorageOCI: PrunableStorage {
|
||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
@@ -16,6 +16,8 @@ class VMStorageOCI {
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
try vmDir.baseURL.updateAccessDate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
@@ -40,10 +42,48 @@ class VMStorageOCI {
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
try gc()
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
var result: [(String, VMDirectory)] = Array()
|
||||
func gc() throws {
|
||||
var refCounts = Dictionary<URL, UInt>()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||
return
|
||||
}
|
||||
|
||||
for case let foundURL as URL in enumerator {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
|
||||
// Perform garbage collection for tag-based images
|
||||
// with broken outgoing references
|
||||
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
|
||||
try FileManager.default.removeItem(at: foundURL)
|
||||
continue
|
||||
}
|
||||
|
||||
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
|
||||
}
|
||||
|
||||
// Perform garbage collection for digest-based images
|
||||
// with no incoming references
|
||||
for (baseURL, incRefCount) in refCounts {
|
||||
let vmDir = VMDirectory(baseURL: baseURL)
|
||||
|
||||
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||
try FileManager.default.removeItem(at: baseURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory, Bool)] {
|
||||
var result: [(String, VMDirectory, Bool)] = Array()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
@@ -60,28 +100,46 @@ class VMStorageOCI {
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
||||
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||
if isSymlink {
|
||||
name = parts.joined(separator: ":")
|
||||
} else {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
result.append((name, vmDir))
|
||||
result.append((name, vmDir, isSymlink))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func prunables() throws -> [Prunable] {
|
||||
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||
|
||||
var digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: try manifest.digest()))
|
||||
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||
reference: Reference(digest: Digest.hash(manifestData)))
|
||||
|
||||
if !exists(digestName) {
|
||||
let tmpVMDir = try VMDirectory.temporary()
|
||||
|
||||
// Try to reclaim some cache space if we know the VM size in advance
|
||||
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
|
||||
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)
|
||||
|
||||
let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
||||
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
||||
|
||||
if availableCapacityBytes < requiredCapacityBytes {
|
||||
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
|
||||
}
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler(operation: {
|
||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
try move(digestName, from: tmpVMDir)
|
||||
@@ -93,8 +151,12 @@ class VMStorageOCI {
|
||||
}
|
||||
|
||||
if name != digestName {
|
||||
// Overwrite the old symbolic link
|
||||
// Create new or overwrite the old symbolic link
|
||||
try link(from: digestName, to: name)
|
||||
} else {
|
||||
// Ensure that images pulled by content digest
|
||||
// are excluded from garbage collection
|
||||
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +166,8 @@ class VMStorageOCI {
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||
|
||||
try gc()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class FullFledgedVNC: VNC {
|
||||
let password: String
|
||||
private let vnc: Dynamic
|
||||
|
||||
init(virtualMachine: VZVirtualMachine) {
|
||||
password = Array(PassphraseGenerator().prefix(4)).joined(separator: "-")
|
||||
let securityConfiguration = Dynamic._VZVNCAuthenticationSecurityConfiguration(password: password)
|
||||
vnc = Dynamic._VZVNCServer(port: 0, queue: DispatchQueue.global(),
|
||||
securityConfiguration: securityConfiguration)
|
||||
vnc.virtualMachine = virtualMachine
|
||||
vnc.start()
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
while true {
|
||||
// Port is 0 shortly after start(),
|
||||
// but will be initialized later
|
||||
if let port = vnc.port.asUInt16, port != 0 {
|
||||
return URL(string: "vnc://:\(password)@127.0.0.1:\(port)")!
|
||||
}
|
||||
|
||||
// Wait 50 ms.
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
vnc.stop()
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? stop()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Dynamic
|
||||
import Virtualization
|
||||
|
||||
class ScreenSharingVNC: VNC {
|
||||
let vmConfig: VMConfig
|
||||
|
||||
init(vmConfig: VMConfig) {
|
||||
self.vmConfig = vmConfig
|
||||
}
|
||||
|
||||
func waitForURL() async throws -> URL {
|
||||
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||
|
||||
if let ip = ip {
|
||||
return URL(string: "vnc://\(ip)")!
|
||||
}
|
||||
|
||||
throw IPNotFound()
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
// nothing to do
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol VNC {
|
||||
func waitForURL() async throws -> URL
|
||||
func stop() throws
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import XCTest
|
||||
import Network
|
||||
@testable import tart
|
||||
|
||||
final class MACAddressResolverTests: XCTestCase {
|
||||
func testSingleEntry() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
}
|
||||
|
||||
func testMultipleEntries() throws {
|
||||
let leases = try Leases("""
|
||||
{
|
||||
ip_address=1.2.3.4
|
||||
hw_address=1,00:11:22:33:44:55
|
||||
}
|
||||
{
|
||||
ip_address=5.6.7.8
|
||||
hw_address=1,AA:BB:CC:DD:EE:FF
|
||||
}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(IPv4Address("1.2.3.4"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "00:11:22:33:44:55")!))
|
||||
XCTAssertEqual(IPv4Address("5.6.7.8"),
|
||||
try leases.resolveMACAddress(macAddress: MACAddress(fromString: "AA:BB:CC:DD:EE:FF")!))
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,13 @@ final class RegistryTests: XCTestCase {
|
||||
XCTAssertEqual(pushedBlob, pulledBlob)
|
||||
}
|
||||
|
||||
func testPushPullBlobHuge() async throws {
|
||||
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)
|
||||
let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush, chunkSizeMb: 10)
|
||||
|
||||
// Pull it
|
||||
var pulledLargeBlob = Data()
|
||||
@@ -62,11 +62,7 @@ final class RegistryTests: XCTestCase {
|
||||
|
||||
func testPushPullManifest() async throws {
|
||||
// Craft a basic config
|
||||
struct OCIConfig: Codable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
let configData = try JSONEncoder().encode(OCIConfig())
|
||||
let configData = try OCIConfig().toJSON()
|
||||
let configDigest = try await registry.pushBlob(fromData: configData)
|
||||
|
||||
// Craft a basic layer
|
||||
|
||||
@@ -11,7 +11,7 @@ final class TokenResponseTests: XCTestCase {
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
@@ -23,9 +23,9 @@ final class TokenResponseTests: XCTestCase {
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
XCTAssertTrue(tokenResponse.isValid())
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
XCTAssertFalse(tokenResponse.isValid())
|
||||
}
|
||||
|
||||
func testExpirationWithIssuedAt() throws {
|
||||
@@ -33,6 +33,6 @@ final class TokenResponseTests: XCTestCase {
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
XCTAssertFalse(tokenResponse.isValid())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class URLAccessDateTests: XCTestCase {
|
||||
func testGetAndSetAccessTime() throws {
|
||||
// Create a temporary file
|
||||
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
|
||||
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)
|
||||
|
||||
// Ensure it's access date is different than our desired access date
|
||||
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
|
||||
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())
|
||||
|
||||
// Set our desired access date for a file
|
||||
try tmpFile.updateAccessDate(arbitraryDate)
|
||||
|
||||
// Ensure the access date has changed to our value
|
||||
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
|
||||
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
source = [".build/arm64-apple-macosx/debug/tart"]
|
||||
bundle_id = "com.github.cirruslabs.tart"
|
||||
|
||||
apple_id {
|
||||
username = "hello@cirruslabs.org"
|
||||
password = "@env:AC_PASSWORD"
|
||||
}
|
||||
|
||||
sign {
|
||||
application_identity = "Developer ID Application: Fedor Korotkov"
|
||||
entitlements_file = "Resources/tart.entitlements"
|
||||
}
|
||||
Reference in New Issue
Block a user