mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-01 19:51:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1bcbdbf0b | ||
|
|
7fec41b2cb | ||
|
|
ea4fb9a2d5 | ||
|
|
c2da3fd919 | ||
|
|
63a2793c32 | ||
|
|
0fc3d3d1f4 | ||
|
|
60b705478b | ||
|
|
fa9e3146c1 | ||
|
|
3dfe8f870c | ||
|
|
7afa446a73 | ||
|
|
d1bed25023 | ||
|
|
b39c3c9b52 | ||
|
|
8554e56e4b | ||
|
|
3fdcc5c33c | ||
|
|
182ddf0268 | ||
|
|
24375c24f3 | ||
|
|
4ebec53c77 | ||
|
|
2f5a6790d8 | ||
|
|
cc697b4f6e | ||
|
|
f6acbe8fe5 | ||
|
|
341fd168b3 | ||
|
|
e8a6efa60a | ||
|
|
efc9c74b6c | ||
|
|
f712ba8ce3 | ||
|
|
4a60c41dd3 | ||
|
|
f20a98bf01 | ||
|
|
d5e3a7718e | ||
|
|
a4db60d656 | ||
|
|
29e08220e4 | ||
|
|
cdf4932aa3 | ||
|
|
95316c0d67 | ||
|
|
4a5efefbc9 | ||
|
|
f07ffed6c8 |
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
cat Sources/tart/CI/CI.swift | envsubst | tee Sources/tart/CI/CI.swift
|
||||
+10
-10
@@ -1,22 +1,22 @@
|
||||
persistent_worker:
|
||||
labels:
|
||||
name: Mac-Mini-M1
|
||||
|
||||
task:
|
||||
name: Test
|
||||
test_script: swift test
|
||||
|
||||
task:
|
||||
name: Build
|
||||
only_if: $CIRRUS_TAG == ''
|
||||
persistent_worker:
|
||||
labels:
|
||||
os: darwin
|
||||
arch: arm64
|
||||
build_script: swift build
|
||||
sign_script: codesign --sign - --entitlements Sources/tart/tart.entitlements --force .build/debug/tart
|
||||
build_script: swift build --product tart
|
||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||
binary_artifacts:
|
||||
path: .build/debug/tart
|
||||
|
||||
task:
|
||||
name: Release
|
||||
only_if: $CIRRUS_TAG != ''
|
||||
persistent_worker:
|
||||
labels:
|
||||
os: darwin
|
||||
arch: arm64
|
||||
env:
|
||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.gif filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
+4
-4
@@ -11,11 +11,9 @@ builds:
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- .ci/set-version.sh
|
||||
- swift build -c release --product tart
|
||||
|
||||
after:
|
||||
hooks:
|
||||
- codesign --sign - --entitlements Sources/tart/tart.entitlements --force .build/arm64-apple-macosx/release/tart
|
||||
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/arm64-apple-macosx/release/tart
|
||||
|
||||
archives:
|
||||
- id: binary
|
||||
@@ -38,3 +36,5 @@ brews:
|
||||
homepage: https://github.com/cirruslabs/tart
|
||||
description: Run macOS VMs on Apple Silicon
|
||||
skip_upload: auto
|
||||
custom_block: |
|
||||
depends_on :macos => :monterey
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="sign debug" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Sources/tart/tart.entitlements --force .build/debug/tart" />
|
||||
<option name="SCRIPT_TEXT" value="codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/sign.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
@@ -14,4 +14,4 @@
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
</component>
|
||||
|
||||
@@ -8,6 +8,33 @@
|
||||
"revision" : "f3c9084a71ef4376f2fabbdf1d3d90a49f1fabdb",
|
||||
"version" : "1.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-case-paths",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-case-paths",
|
||||
"state" : {
|
||||
"revision" : "ce9c0d897db8a840c39de64caaa9b60119cf4be8",
|
||||
"version" : "0.8.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-parsing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-parsing",
|
||||
"state" : {
|
||||
"revision" : "28d32e9ace1c4c43f5e5a177be837a202494c2d5",
|
||||
"version" : "0.9.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "50a70a9d3583fe228ce672e8923010c8df2deddd",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
+6
-7
@@ -12,14 +12,13 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.1.2"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(name: "tart",
|
||||
dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
],
|
||||
resources: [
|
||||
.process("Resources/AppIcon.png")
|
||||
]),
|
||||
.executableTarget(name: "tart", dependencies: [
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
.product(name: "Parsing", package: "swift-parsing"),
|
||||
]),
|
||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1,3 +1,193 @@
|
||||
# Tart
|
||||

|
||||
|
||||
macOS VMs on Apple Silicon to use in CI and other automations
|
||||
*Tart* is a virtualization toolset to build, run and manage 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).
|
||||
* Push/Pull virtual machines from any OCI-compatible container registry.
|
||||
* Use Tart Packer Plugin to automate VM creation.
|
||||
* Built-in CI integration.
|
||||
|
||||
Try running a Tart VM on your Apple Silicon device running macOS Monterey or later (will download a 25 GB image):
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/tart
|
||||
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
|
||||
tart run monterey-base
|
||||
```
|
||||
|
||||

|
||||
|
||||
## CI Integration
|
||||
|
||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
||||
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
|
||||
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||
We built Cirrus CLI to solve "But it works on my machine!" problem.
|
||||
|
||||
Here is an example of a `.cirrus.yml` configuration file which will start a Tart VM, will copy over working directory and
|
||||
will run scripts and [other instructions](https://cirrus-ci.org/guide/writing-tasks/#supported-instructions) inside the virtual machine:
|
||||
|
||||
```yaml
|
||||
task:
|
||||
name: hello
|
||||
macos_instance:
|
||||
# can be a remote or a local virtual machine
|
||||
image: ghcr.io/cirruslabs/macos-monterey-base:latest
|
||||
hello_script:
|
||||
- echo "Hello from within a Tart VM!"
|
||||
- echo "Here is my CPU info:"
|
||||
- sysctl -n machdep.cpu.brand_string
|
||||
- sleep 15
|
||||
```
|
||||
|
||||
Put the above `.cirrus.yml` file in the root of your repository and run it with the following command:
|
||||
|
||||
```shell
|
||||
brew install cirruslabs/cli/cirrus
|
||||
cirrus run
|
||||
```
|
||||
|
||||

|
||||
|
||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
||||
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
|
||||
|
||||
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
## Virtual Machine Management
|
||||
|
||||
### Creating 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:
|
||||
|
||||
```shell
|
||||
tart create --from-ipsw=latest monterey-vanilla
|
||||
tart run monterey-vanilla
|
||||
```
|
||||
|
||||
After the initial booting of the VM you'll need to manually go through the macOS installation process. As a convention we recommend creating an `admin` user with an `admin` password. After the regular installation please do some additional modifications in the VM:
|
||||
|
||||
1. Enable Auto-Login. Users & Groups -> Login Options -> Automatic login -> admin.
|
||||
2. Allow SSH. Sharing -> Remote Login
|
||||
3. Disable Lock Screen. Preferences -> Lock Screen -> disable "Require Password" after 5.
|
||||
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.
|
||||
|
||||
### 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.
|
||||
Please refer to `tart set --help` for additional details.
|
||||
|
||||
### Building with Packer
|
||||
|
||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||
|
||||
```json
|
||||
{
|
||||
"builders": [
|
||||
{
|
||||
"name": "tart",
|
||||
"type": "tart-cli",
|
||||
"vm_base_name": "tartvm/vanilla:latest",
|
||||
"vm_name": "monterey-base",
|
||||
"cpu_count": 4,
|
||||
"memory_gb": 8,
|
||||
"disk_size_gb": 32,
|
||||
"ssh_username": "admin",
|
||||
"ssh_password": "admin",
|
||||
"ssh_timeout": "120s"
|
||||
}
|
||||
],
|
||||
"provisioners": [
|
||||
{
|
||||
"inline": [
|
||||
"echo 'Disabling spotlight indexing...'",
|
||||
"sudo mdutil -a -i off"
|
||||
],
|
||||
"type": "shell"
|
||||
},
|
||||
# more provisioners
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Here is a [repository with Packer templates](https://github.com/cirruslabs/macos-image-templates) used to build [all the images managed by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||
|
||||
### Working with a Remote OCI Container Registry
|
||||
|
||||
For example, let's say you want to push/pull images to a registry hosted at https://acme.io/.
|
||||
|
||||
#### Registry Authorization
|
||||
|
||||
First, you need to log in and save credential for `acme.io` host via `tart login` command:
|
||||
|
||||
```shell
|
||||
tart login acme.io
|
||||
```
|
||||
|
||||
Credentials are securely stored in Keychain.
|
||||
|
||||
#### Pushing a Local Image
|
||||
|
||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||
|
||||
```shell
|
||||
tart push my-local-vm-name acme.io/remoteorg/name:latest acme.io/remoteorg/name:v1.0.0
|
||||
```
|
||||
|
||||
#### Pulling a Remote Image
|
||||
|
||||
```shell
|
||||
tart pull acme.io/remoteorg/name:latest my-local-vm-name
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
<details>
|
||||
<summary>How Tart is different from Anka</summary>
|
||||
|
||||
Under the hood Tart is using the same technology as Anka 3.0 so there should be no real difference in performance
|
||||
or features supported. If there is some feature missing please don't hesitate to [create a feature request](https://github.com/cirruslabs/tart/issues).
|
||||
|
||||
Instead of Anka Registry, Tart can work with any OCI-compatible container registry.
|
||||
|
||||
Tart doesn't yet have an analogue of Anka Controller for managing long living VMs. Please take a look at [CI integration](#ci-integration)
|
||||
section for an option to run ephemeral VMs for your needs.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Why Tart is free and open sourced?</summary>
|
||||
|
||||
Tart is a relatively small project, and it didn't feel right to try to monetize it.
|
||||
Apple did all the heavy lifting with their `Virtualization.Framework`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>How to change VM's disk size?</summary>
|
||||
|
||||
You can choose disk size upon creation of a virtual machine:
|
||||
|
||||
```shell
|
||||
tart create --from-ipsw=latest --disk-size=25 monterey-vanilla
|
||||
```
|
||||
|
||||
For an existing VM please use [Packer Plugin](https://github.com/cirruslabs/packer-plugin-tart) which can increase
|
||||
disk size for new virtual machines. Here is an example of [how to change disk size in a Packer template](https://github.com/cirruslabs/macos-image-templates/blob/fb0bcf68e0b093129136875c050205a66729b596/templates/base.pkr.hcl#L15).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>VM location on disk</summary>
|
||||
|
||||
Tart stores all it's files in `~/.tart/` directory. Local images that you can run are stored in `~/.tart/vms/`.
|
||||
Remote images are pulled into `~/.tart/vms/cache/OCIs/`.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Nested virtualization support?</summary>
|
||||
|
||||
Tart is limited by functionality of Apple's `Virtualization.Framework`. At the moment `Virtualization.Framework`
|
||||
doesn't support nested virtualization.
|
||||
</details>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8a3a324193c4bd7797102765ab16f44adf58e49ca615bac3963cefd0d3a10594
|
||||
size 339678
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
|
||||
size 205325
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:769dcd5411f44071cb46f63459118fef728c866a581cf94a28811f407ca9827d
|
||||
size 606936
|
||||
@@ -0,0 +1,13 @@
|
||||
struct CI {
|
||||
private static let rawVersion = "${CIRRUS_TAG}"
|
||||
|
||||
static var version: String {
|
||||
rawVersion.expanded() ? rawVersion : "SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func expanded() -> Bool {
|
||||
!isEmpty && !starts(with: "$")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SystemConfiguration
|
||||
import Virtualization
|
||||
|
||||
struct Clone: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Clone a VM")
|
||||
@@ -14,17 +13,21 @@ struct Clone: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmStorage = VMStorage()
|
||||
let sourceVMDir = try vmStorage.read(sourceName)
|
||||
let newVMDir = try vmStorage.create(newName)
|
||||
if let remoteName = try? RemoteName(sourceName) {
|
||||
if !VMStorageOCI().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)
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
}
|
||||
let remoteVM = try VMStorageHelper.open(sourceName)
|
||||
|
||||
try FileManager.default.copyItem(at: sourceVMDir.configURL, to: newVMDir.configURL)
|
||||
try FileManager.default.copyItem(at: sourceVMDir.nvramURL, to: newVMDir.nvramURL)
|
||||
try FileManager.default.copyItem(at: sourceVMDir.diskURL, to: newVMDir.diskURL)
|
||||
let remoteConfig = try VMConfig.init(fromURL: remoteVM.configURL)
|
||||
let needToGenerateNewMAC = try localVMExistsWith(macAddress: remoteConfig.macAddress.string)
|
||||
|
||||
var newVMConfig = try VMConfig(fromURL: newVMDir.configURL)
|
||||
newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
try newVMConfig.save(toURL: newVMDir.configURL)
|
||||
try remoteVM.clone(to: VMStorageLocal().create(newName), generateMAC: needToGenerateNewMAC)
|
||||
} else {
|
||||
try VMStorageHelper.open(sourceName).clone(to: VMStorageLocal().create(newName), generateMAC: true)
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -33,4 +36,15 @@ struct Clone: AsyncParsableCommand {
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func localVMExistsWith(macAddress: String) throws -> Bool {
|
||||
var needToGenerateNewMAC = false
|
||||
for (_, localDir) in try VMStorageLocal().list() {
|
||||
let localConfig = try VMConfig.init(fromURL: localDir.configURL)
|
||||
if localConfig.macAddress.string == macAddress {
|
||||
needToGenerateNewMAC = true
|
||||
}
|
||||
}
|
||||
return needToGenerateNewMAC
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ struct Create: AsyncParsableCommand {
|
||||
var fromIPSW: String?
|
||||
|
||||
@Option(help: ArgumentHelp("Disk size in Gb"))
|
||||
var diskSize: UInt8 = 32
|
||||
var diskSize: UInt8 = 50
|
||||
|
||||
func validate() throws {
|
||||
if fromIPSW == nil {
|
||||
@@ -23,7 +23,7 @@ struct Create: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorage().create(name)
|
||||
let vmDir = try VMStorageLocal().create(name)
|
||||
|
||||
if fromIPSW! == "latest" {
|
||||
_ = try await VM(vmDir: vmDir, ipswURL: nil, diskSizeGB: diskSize)
|
||||
|
||||
@@ -10,7 +10,7 @@ struct Delete: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
try VMStorage().delete(name)
|
||||
try VMStorageHelper.delete(name)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Network
|
||||
import SystemConfiguration
|
||||
|
||||
struct IP: AsyncParsableCommand {
|
||||
@@ -8,13 +9,15 @@ struct IP: AsyncParsableCommand {
|
||||
@Argument(help: "VM name")
|
||||
var name: String
|
||||
|
||||
@Option(help: "Number of seconds to wait for a potential VM booting")
|
||||
var wait: UInt16 = 0
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmDir = try VMStorage().read(name)
|
||||
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 ARPCache.ResolveMACAddress(macAddress: vmMacAddress) else {
|
||||
guard let ip = try await resolveIP(vmConfig, secondsToWait: wait) else {
|
||||
print("no IP address found, is your VM running?")
|
||||
|
||||
Foundation.exit(1)
|
||||
@@ -29,4 +32,19 @@ struct IP: AsyncParsableCommand {
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveIP(_ config: VMConfig, 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) {
|
||||
return ip
|
||||
}
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
} while Date.now < waitUntil
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,10 @@ struct List: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
for vmURL in try VMStorage().list() {
|
||||
print(vmURL)
|
||||
}
|
||||
print("Name\tSource")
|
||||
|
||||
displayTable("local", try VMStorageLocal().list())
|
||||
displayTable("oci", try VMStorageOCI().list())
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
@@ -18,4 +19,10 @@ struct List: AsyncParsableCommand {
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
||||
for (name, _) in vms {
|
||||
print("\(source)\t\(name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Login: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Login to a registry")
|
||||
|
||||
@Argument(help: "host")
|
||||
var host: String
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let (user, password) = try Credentials.retrieveStdin()
|
||||
|
||||
try Credentials.store(host: host, user: user, password: password)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import SwiftUI
|
||||
|
||||
struct Pull: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
|
||||
|
||||
@Argument(help: "remote VM name")
|
||||
var remoteName: String
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
// Be more liberal when accepting local image as argument,
|
||||
// see https://github.com/cirruslabs/tart/issues/36
|
||||
if VMStorageLocal().exists(remoteName) {
|
||||
print("\"\(remoteName)\" is a local image, nothing to pull here!")
|
||||
|
||||
Foundation.exit(0)
|
||||
}
|
||||
|
||||
let remoteName = try RemoteName(remoteName)
|
||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
||||
|
||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||
|
||||
try await VMStorageOCI().pull(remoteName, registry: registry)
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import ArgumentParser
|
||||
import Dispatch
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
struct Push: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(abstract: "Push a VM to a registry")
|
||||
|
||||
@Argument(help: "local VM name")
|
||||
var localName: String
|
||||
|
||||
@Argument(help: "remote VM name(s)")
|
||||
var remoteNames: [String]
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let localVMDir = try VMStorageLocal().open(localName)
|
||||
|
||||
// Parse remote names supplied by the user
|
||||
let remoteNames = try remoteNames.map{
|
||||
try RemoteName($0)
|
||||
}
|
||||
|
||||
// Group remote names by registry
|
||||
struct RegistryIdentifier: Hashable, Equatable {
|
||||
var host: String
|
||||
var namespace: String
|
||||
}
|
||||
|
||||
let registryGroups = Dictionary(grouping: remoteNames, by: {
|
||||
RegistryIdentifier(host: $0.host, namespace: $0.namespace)
|
||||
})
|
||||
|
||||
// Push VM
|
||||
for (registryIdentifier, remoteNamesForRegistry) in registryGroups {
|
||||
let registry = try Registry(host: registryIdentifier.host, namespace: registryIdentifier.namespace)
|
||||
|
||||
let listOfTagsAndDigests = "{" + remoteNamesForRegistry.map{$0.fullyQualifiedReference }
|
||||
.joined(separator: ",") + "}"
|
||||
defaultLogger.appendNewLine("pushing \(localName) to "
|
||||
+ "\(registryIdentifier.host)/\(registryIdentifier.namespace)\(listOfTagsAndDigests)...")
|
||||
|
||||
try await localVMDir.pushToRegistry(registry: registry, references: remoteNamesForRegistry.map{ $0.reference })
|
||||
}
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,56 +12,75 @@ struct Run: AsyncParsableCommand {
|
||||
var name: String
|
||||
|
||||
@Flag var noGraphics: Bool = false
|
||||
|
||||
|
||||
@MainActor
|
||||
func run() async throws {
|
||||
let vmDir = try VMStorage().read(name)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
vm = try VM(vmDir: vmDir)
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await vm!.run()
|
||||
await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
do {
|
||||
try await vm!.run()
|
||||
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
Foundation.exit(0)
|
||||
} catch {
|
||||
print(error)
|
||||
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if noGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
// UI mumbo-jumbo
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let icon = Bundle.module.image(forResource: "AppIcon.png")
|
||||
nsApp.applicationIconImage = icon
|
||||
|
||||
struct MainApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
}
|
||||
Foundation.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.main()
|
||||
if noGraphics {
|
||||
dispatchMain()
|
||||
} else {
|
||||
runUI()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runUI() {
|
||||
let nsApp = NSApplication.shared
|
||||
nsApp.setActivationPolicy(.regular)
|
||||
nsApp.activate(ignoringOtherApps: true)
|
||||
|
||||
nsApp.applicationIconImage = NSImage(data: AppIconData)
|
||||
|
||||
struct MainApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup(vm!.name) {
|
||||
Group {
|
||||
VMView(vm: vm!).onAppear {
|
||||
NSWindow.allowsAutomaticWindowTabbing = false
|
||||
}
|
||||
}.frame(width: CGFloat(vm!.config.display.width), height: CGFloat(vm!.config.display.height))
|
||||
}.commands {
|
||||
// Remove some standard menu options
|
||||
CommandGroup(replacing: .help, addition: {})
|
||||
CommandGroup(replacing: .newItem, addition: {})
|
||||
CommandGroup(replacing: .pasteboard, addition: {})
|
||||
CommandGroup(replacing: .textEditing, addition: {})
|
||||
CommandGroup(replacing: .undoRedo, addition: {})
|
||||
CommandGroup(replacing: .windowSize, addition: {})
|
||||
// Replace some standard menu options
|
||||
CommandGroup(replacing: .appInfo) { AboutTart() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MainApp.main()
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutTart: View {
|
||||
var body: some View {
|
||||
Button("About Tart") {
|
||||
NSApplication.shared.orderFrontStandardAboutPanel(options: [
|
||||
NSApplication.AboutPanelOptionKey.applicationIcon: NSApplication.shared.applicationIconImage as Any,
|
||||
NSApplication.AboutPanelOptionKey.applicationName: "Tart",
|
||||
NSApplication.AboutPanelOptionKey.applicationVersion: CI.version,
|
||||
NSApplication.AboutPanelOptionKey.credits: try! NSAttributedString(markdown: "https://github.com/cirruslabs/tart"),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ struct Set: AsyncParsableCommand {
|
||||
|
||||
func run() async throws {
|
||||
do {
|
||||
let vmStorage = VMStorage()
|
||||
let vmDir = try vmStorage.read(name)
|
||||
let vmDir = try VMStorageLocal().open(name)
|
||||
var vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||
|
||||
if let cpu = cpu {
|
||||
@@ -46,7 +45,7 @@ struct Set: AsyncParsableCommand {
|
||||
}
|
||||
|
||||
try vmConfig.save(toURL: vmDir.configURL)
|
||||
|
||||
|
||||
if diskSize != nil {
|
||||
try vmDir.resizeDisk(diskSize!)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct Config {
|
||||
public static let tartHomeDir: URL = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
|
||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
enum CredentialsError: Error {
|
||||
case CredentialRequired(which: String)
|
||||
case CredentialTooLong(message: String)
|
||||
}
|
||||
|
||||
class Credentials {
|
||||
static func retrieveKeychain(host: String) throws -> (String, String)? {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
|
||||
kSecAttrProtocol as String: kSecAttrProtocolHTTPS,
|
||||
kSecAttrServer as String: host,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecReturnData as String: true,
|
||||
kSecAttrLabel as String: "Tart Credentials",
|
||||
]
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
if status != errSecSuccess {
|
||||
if status == errSecItemNotFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
|
||||
}
|
||||
|
||||
guard let item = item as? [String: Any],
|
||||
let user = item[kSecAttrAccount as String] as? String,
|
||||
let passwordData = item[kSecValueData as String] as? Data,
|
||||
let password = String(data: passwordData, encoding: .utf8)
|
||||
else {
|
||||
throw RegistryError.AuthFailed(why: "Keychain item has unexpected format")
|
||||
}
|
||||
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
static func retrieveStdin() throws -> (String, String) {
|
||||
let user = try readStdinCredential(name: "username", prompt: "User: ", isSensitive: false)
|
||||
let password = try readStdinCredential(name: "password", prompt: "Password: ", isSensitive: true)
|
||||
|
||||
return (user, password)
|
||||
}
|
||||
|
||||
private static func readStdinCredential(name: String, prompt: String, maxCharacters: Int = 255, isSensitive: Bool) throws -> String {
|
||||
var buf = [CChar](repeating: 0, count: maxCharacters + 1 /* sentinel */ + 1 /* NUL */)
|
||||
guard let rawCredential = readpassphrase(prompt, &buf, buf.count, isSensitive ? RPP_ECHO_OFF : RPP_ECHO_ON) else {
|
||||
throw CredentialsError.CredentialRequired(which: name)
|
||||
}
|
||||
|
||||
let credential = String(cString: rawCredential).trimmingCharacters(in: .newlines)
|
||||
|
||||
if credential.count > maxCharacters {
|
||||
throw CredentialsError.CredentialTooLong(
|
||||
message: "\(name) should contain no more than \(maxCharacters) characters")
|
||||
}
|
||||
|
||||
return credential
|
||||
}
|
||||
|
||||
static 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 status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
|
||||
switch status {
|
||||
case errSecSuccess, errSecDuplicateItem:
|
||||
return
|
||||
default:
|
||||
throw RegistryError.AuthFailed(why: "Keychain returned unsuccessful status \(status)")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
class Digest {
|
||||
var hash: SHA256 = SHA256()
|
||||
|
||||
func update(_ data: Data) {
|
||||
hash.update(data: data)
|
||||
}
|
||||
|
||||
func finalize() -> String {
|
||||
hash.finalize().hexdigest()
|
||||
}
|
||||
|
||||
static func hash(_ data: Data) -> String {
|
||||
SHA256.hash(data: data).hexdigest()
|
||||
}
|
||||
}
|
||||
|
||||
extension SHA256.Digest {
|
||||
func hexdigest() -> String {
|
||||
"sha256:" + self.map {
|
||||
String(format: "%02x", $0)
|
||||
}
|
||||
.joined()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json"
|
||||
let ociConfigMediaType = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
struct OCIManifest: Encodable, Decodable {
|
||||
var schemaVersion: Int = 2
|
||||
var mediaType: String = ociManifestMediaType
|
||||
var config: OCIManifestConfig
|
||||
var layers: [OCIManifestLayer] = Array()
|
||||
}
|
||||
|
||||
struct OCIManifestConfig: Encodable, Decodable {
|
||||
var mediaType: String = ociConfigMediaType
|
||||
var size: Int
|
||||
var digest: String
|
||||
}
|
||||
|
||||
struct OCIManifestLayer: Encodable, Decodable {
|
||||
var mediaType: String
|
||||
var size: Int
|
||||
var digest: String
|
||||
}
|
||||
|
||||
struct Descriptor {
|
||||
var size: Int
|
||||
var digest: String
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import Foundation
|
||||
|
||||
enum RegistryError: Error {
|
||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||
case MissingLocationHeader
|
||||
case AuthFailed(why: String, details: String = "")
|
||||
case MalformedHeader(why: String)
|
||||
}
|
||||
|
||||
struct TokenResponse: Decodable {
|
||||
let defaultIssuedAt = Date()
|
||||
let defaultExpiresIn = 60
|
||||
|
||||
var token: String
|
||||
var expiresIn: Int?
|
||||
var issuedAt: Date?
|
||||
|
||||
static func parse(fromData: Data) throws -> Self {
|
||||
let decoder = 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"
|
||||
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
|
||||
decoder.dateDecodingStrategy = .formatted(dateFormatter)
|
||||
|
||||
return try decoder.decode(TokenResponse.self, from: fromData)
|
||||
}
|
||||
|
||||
var tokenExpiresAt: Date {
|
||||
get {
|
||||
// Tokens can expire and expire_in field is used to determine when:
|
||||
//
|
||||
// >The duration in seconds since the token was issued that it will remain valid.
|
||||
// >When omitted, this defaults to 60 seconds. For compatibility with older clients,
|
||||
// >a token should never be returned with less than 60 seconds to live.
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/#requesting-a-token
|
||||
|
||||
(issuedAt ?? defaultIssuedAt) + TimeInterval(expiresIn ?? defaultExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
get {
|
||||
Date() < tokenExpiresAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Registry {
|
||||
var baseURL: URL
|
||||
var namespace: String
|
||||
|
||||
var currentAuthToken: TokenResponse? = nil
|
||||
|
||||
init(host: String, namespace: String) throws {
|
||||
var baseURLComponents = URLComponents()
|
||||
baseURLComponents.scheme = "https"
|
||||
baseURLComponents.host = host
|
||||
baseURLComponents.path = "/v2/"
|
||||
|
||||
baseURL = baseURLComponents.url!
|
||||
self.namespace = namespace
|
||||
}
|
||||
|
||||
func pushManifest(reference: String, config: Descriptor, layers: [OCIManifestLayer]) async throws -> String {
|
||||
let manifest = OCIManifest(config: OCIManifestConfig(size: config.size, digest: config.digest),
|
||||
layers: layers)
|
||||
let manifestJSON = try JSONEncoder().encode(manifest)
|
||||
|
||||
let (responseData, response) = try await endpointRequest("PUT", "\(namespace)/manifests/\(reference)",
|
||||
headers: ["Content-Type": manifest.mediaType],
|
||||
body: manifestJSON)
|
||||
if response.statusCode != 201 {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing manifest", code: response.statusCode,
|
||||
details: String(decoding: responseData, as: UTF8.self))
|
||||
}
|
||||
|
||||
return Digest.hash(manifestJSON)
|
||||
}
|
||||
|
||||
public func pullManifest(reference: String) async throws -> (OCIManifest, Data) {
|
||||
let (responseData, response) = try await endpointRequest("GET", "\(namespace)/manifests/\(reference)",
|
||||
headers: ["Accept": ociManifestMediaType])
|
||||
if response.statusCode != 200 {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling manifest", code: response.statusCode,
|
||||
details: String(decoding: responseData, as: UTF8.self))
|
||||
}
|
||||
|
||||
let manifest = try JSONDecoder().decode(OCIManifest.self, from: responseData)
|
||||
|
||||
return (manifest, responseData)
|
||||
}
|
||||
|
||||
private func uploadLocationFromResponse(response: HTTPURLResponse) throws -> URLComponents {
|
||||
guard let uploadLocationRaw = response.value(forHTTPHeaderField: "Location") else {
|
||||
throw RegistryError.MissingLocationHeader
|
||||
}
|
||||
|
||||
guard let uploadLocation = URL(string: uploadLocationRaw) else {
|
||||
throw RegistryError.MalformedHeader(why: "Location header contains invalid URL: \"\(uploadLocationRaw)\"")
|
||||
}
|
||||
|
||||
return URLComponents(url: uploadLocation.absolutize(baseURL), resolvingAgainstBaseURL: true)!
|
||||
}
|
||||
|
||||
public func pushBlob(fromData: Data, chunkSize: Int = 5 * 1024 * 1024) async throws -> String {
|
||||
// Initiate a blob upload
|
||||
let (postData, postResponse) = try await endpointRequest("POST", "\(namespace)/blobs/uploads/",
|
||||
headers: ["Content-Length": "0"])
|
||||
if postResponse.statusCode != 202 {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (POST)", code: postResponse.statusCode,
|
||||
details: String(decoding: postData, as: UTF8.self))
|
||||
}
|
||||
|
||||
// Figure out where to upload the blob
|
||||
let uploadLocation = try uploadLocationFromResponse(response: postResponse)
|
||||
|
||||
// Upload the blob
|
||||
let headers = [
|
||||
"Content-Length": "\(fromData.count)",
|
||||
"Content-Type": "application/octet-stream",
|
||||
]
|
||||
|
||||
let digest = Digest.hash(fromData)
|
||||
let parameters = [
|
||||
"digest": digest,
|
||||
]
|
||||
|
||||
let (putData, putResponse) = try await rawRequest("PUT", uploadLocation, headers: headers, parameters: parameters,
|
||||
body: fromData)
|
||||
if putResponse.statusCode != 201 {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pushing blob (PUT)", code: putResponse.statusCode,
|
||||
details: String(decoding: putData, as: UTF8.self))
|
||||
}
|
||||
|
||||
return digest
|
||||
}
|
||||
|
||||
public func pullBlob(_ digest: String) async throws -> Data {
|
||||
let (putData, putResponse) = try await endpointRequest("GET", "\(namespace)/blobs/\(digest)")
|
||||
if putResponse.statusCode != 200 {
|
||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: putResponse.statusCode,
|
||||
details: String(decoding: putData, as: UTF8.self))
|
||||
}
|
||||
|
||||
return putData
|
||||
}
|
||||
|
||||
private func endpointRequest(
|
||||
_ method: String,
|
||||
_ endpoint: String,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
let url = URL(string: endpoint, relativeTo: baseURL)!
|
||||
let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: true)!
|
||||
|
||||
return try await rawRequest(method, urlComponents, headers: headers, parameters: parameters, body: body)
|
||||
}
|
||||
|
||||
private func rawRequest(
|
||||
_ method: String,
|
||||
_ urlComponents: URLComponents,
|
||||
headers: Dictionary<String, String> = Dictionary(),
|
||||
parameters: Dictionary<String, String> = Dictionary(),
|
||||
body: Data? = nil
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil {
|
||||
urlComponents.queryItems = []
|
||||
}
|
||||
urlComponents.queryItems?.append(contentsOf: parameters.map { key, value -> URLQueryItem in
|
||||
URLQueryItem(name: key, value: value)
|
||||
})
|
||||
|
||||
var request = URLRequest(url: urlComponents.url!)
|
||||
request.httpMethod = method
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
request.httpBody = body
|
||||
|
||||
// Invalidate token if it has expired
|
||||
if currentAuthToken?.isValid == false {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var (data, response) = try await authAwareRequest(request: request)
|
||||
|
||||
if response.statusCode == 401 {
|
||||
try await auth(response: response)
|
||||
(data, response) = try await authAwareRequest(request: request)
|
||||
}
|
||||
|
||||
return (data, response)
|
||||
}
|
||||
|
||||
private func auth(response: HTTPURLResponse) async throws {
|
||||
// Process WWW-Authenticate header
|
||||
guard let wwwAuthenticateRaw = response.value(forHTTPHeaderField: "WWW-Authenticate") else {
|
||||
throw RegistryError.AuthFailed(why: "got HTTP 401, but WWW-Authenticate header is missing")
|
||||
}
|
||||
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: wwwAuthenticateRaw)
|
||||
if wwwAuthenticate.scheme != "Bearer" {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's authentication scheme "
|
||||
+ "\"\(wwwAuthenticate.scheme)\" is unsupported, expected \"Bearer\" scheme")
|
||||
}
|
||||
guard let realm = wwwAuthenticate.kvs["realm"] else {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header is missing a \"realm\" directive")
|
||||
}
|
||||
|
||||
// Request a token
|
||||
guard var authenticateURL = URLComponents(string: realm) else {
|
||||
throw RegistryError.AuthFailed(why: "WWW-Authenticate header's realm directive "
|
||||
+ "\"\(realm)\" doesn't look like URL")
|
||||
}
|
||||
|
||||
// Token Authentication Specification[1]:
|
||||
//
|
||||
// >To respond to this challenge, the client will need to make a GET request
|
||||
// >[...] using the service and scope values from the WWW-Authenticate header.
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/
|
||||
authenticateURL.queryItems = ["scope", "service"].compactMap { key in
|
||||
if let value = wwwAuthenticate.kvs[key] {
|
||||
return URLQueryItem(name: key, value: value)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var headers: Dictionary<String, String> = Dictionary()
|
||||
|
||||
if let (user, password) = try Credentials.retrieveKeychain(host: baseURL.host!) {
|
||||
let encodedCredentials = "\(user):\(password)".data(using: .utf8)?.base64EncodedString()
|
||||
headers["Authorization"] = "Basic \(encodedCredentials!)"
|
||||
}
|
||||
|
||||
let (tokenResponseRaw, response) = try await rawRequest("GET", authenticateURL, headers: headers)
|
||||
if response.statusCode != 200 {
|
||||
throw RegistryError.AuthFailed(why: "received unexpected HTTP status code \(response.statusCode) "
|
||||
+ "while retrieving an authentication token", details: String(decoding: tokenResponseRaw, as: UTF8.self))
|
||||
}
|
||||
|
||||
currentAuthToken = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
request.addValue("Bearer \(token.token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let (responseData, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
return (responseData, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Foundation
|
||||
import Parsing
|
||||
|
||||
struct Tail {
|
||||
enum TailType {
|
||||
case Tag
|
||||
case Digest
|
||||
}
|
||||
|
||||
var type: TailType
|
||||
var value: String
|
||||
}
|
||||
|
||||
struct RemoteName: Comparable, CustomStringConvertible {
|
||||
var host: String
|
||||
var namespace: String
|
||||
var reference: String = "latest"
|
||||
var fullyQualifiedReference: String {
|
||||
get {
|
||||
if reference.starts(with: "sha256:") {
|
||||
return "@" + reference
|
||||
}
|
||||
|
||||
return ":" + reference
|
||||
}
|
||||
}
|
||||
|
||||
init(host: String, namespace: String, reference: String) {
|
||||
self.host = host
|
||||
self.namespace = namespace
|
||||
self.reference = reference
|
||||
}
|
||||
|
||||
init(_ name: String) throws {
|
||||
let csNormal = [
|
||||
UInt8(ascii: "a")...UInt8(ascii: "z"),
|
||||
UInt8(ascii: "A")...UInt8(ascii: "Z"),
|
||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
].asCharacterSet().union(CharacterSet(charactersIn: "_-."))
|
||||
|
||||
let csHex = [
|
||||
UInt8(ascii: "a")...UInt8(ascii: "f"),
|
||||
UInt8(ascii: "0")...UInt8(ascii: "9"),
|
||||
].asCharacterSet()
|
||||
|
||||
let parser = Parse {
|
||||
Consumed {
|
||||
csNormal
|
||||
Optionally {
|
||||
":"
|
||||
Digits()
|
||||
}
|
||||
}
|
||||
"/"
|
||||
csNormal.union(CharacterSet(charactersIn: "/"))
|
||||
Optionally {
|
||||
OneOf {
|
||||
Parse {
|
||||
":"
|
||||
csNormal.map {
|
||||
Tail(type: .Tag, value: String($0))
|
||||
}
|
||||
}
|
||||
Parse {
|
||||
"@sha256:"
|
||||
csHex.map {
|
||||
Tail(type: .Digest, value: "sha256:" + String($0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
End()
|
||||
}
|
||||
|
||||
let result = try parser.parse(name)
|
||||
|
||||
host = String(result.0)
|
||||
namespace = String(result.1)
|
||||
if let tail = result.2 {
|
||||
reference = tail.value
|
||||
}
|
||||
}
|
||||
|
||||
static func <(lhs: RemoteName, rhs: RemoteName) -> Bool {
|
||||
if lhs.host != rhs.host {
|
||||
return lhs.host < rhs.host
|
||||
} else if lhs.namespace != rhs.namespace {
|
||||
return lhs.namespace < rhs.namespace
|
||||
} else {
|
||||
return lhs.reference < rhs.reference
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
"\(host)/\(namespace)\(fullyQualifiedReference)"
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Self.Element == ClosedRange<UInt8> {
|
||||
func asCharacterSet() -> CharacterSet {
|
||||
let characters = self.joined().map { String(UnicodeScalar($0)) }.joined()
|
||||
return CharacterSet(charactersIn: characters)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
func absolutize(_ baseURL: URL) -> Self {
|
||||
URL(string: absoluteString, relativeTo: baseURL)!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
// WWW-Authenticate header parser based on details from RFCs[1][2]
|
||||
///
|
||||
// [1]: https://www.rfc-editor.org/rfc/rfc2617#section-3.2.1
|
||||
// [2]: https://www.rfc-editor.org/rfc/rfc6750#section-3
|
||||
class WWWAuthenticate {
|
||||
var scheme: String
|
||||
var kvs: Dictionary<String, String> = Dictionary()
|
||||
|
||||
init(rawHeaderValue: String) throws {
|
||||
let splits = rawHeaderValue.split(separator: " ", maxSplits: 1)
|
||||
|
||||
if splits.count == 2 {
|
||||
scheme = String(splits[0])
|
||||
} else {
|
||||
throw RegistryError.MalformedHeader(why: "WWW-Authenticate header should consist of two parts: "
|
||||
+ "scheme and directives")
|
||||
}
|
||||
|
||||
let rawDirectives = contextAwareCommaSplit(rawDirectives: String(splits[1]))
|
||||
|
||||
try rawDirectives.forEach { sequence in
|
||||
let parts = sequence.split(separator: "=", maxSplits: 1)
|
||||
if parts.count != 2 {
|
||||
throw RegistryError.MalformedHeader(why: "Each WWW-Authenticate header directive should be in the form of "
|
||||
+ "key=value or key=\"value\"")
|
||||
}
|
||||
|
||||
let key = String(parts[0])
|
||||
var value = String(parts[1])
|
||||
value = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
|
||||
kvs[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
private func contextAwareCommaSplit(rawDirectives: String) -> Array<String> {
|
||||
var result: Array<String> = Array()
|
||||
var inQuotation: Bool = false
|
||||
var accumulator: Array<Character> = Array()
|
||||
|
||||
for ch in rawDirectives {
|
||||
if ch == "," && !inQuotation {
|
||||
result.append(String(accumulator))
|
||||
accumulator.removeAll()
|
||||
continue
|
||||
}
|
||||
|
||||
accumulator.append(ch)
|
||||
|
||||
if ch == "\"" {
|
||||
inQuotation.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
if !accumulator.isEmpty {
|
||||
result.append(String(accumulator))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -1,8 +1,44 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct Root: AsyncParsableCommand {
|
||||
static var configuration = CommandConfiguration(
|
||||
commandName: "tart",
|
||||
subcommands: [Create.self, Clone.self, Run.self, Set.self, List.self, IP.self, Delete.self])
|
||||
version: CI.version,
|
||||
subcommands: [
|
||||
Create.self,
|
||||
Clone.self,
|
||||
Run.self,
|
||||
Set.self,
|
||||
List.self,
|
||||
Login.self,
|
||||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Delete.self,
|
||||
])
|
||||
|
||||
public static func main() async throws {
|
||||
// Handle cancellation by Ctrl+C
|
||||
let task = withUnsafeCurrentTask { $0 }!
|
||||
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSrc.setEventHandler {
|
||||
task.cancel()
|
||||
}
|
||||
sigintSrc.activate()
|
||||
|
||||
// Parse and run command
|
||||
do {
|
||||
var command = try parseAsRoot()
|
||||
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
exit(withError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -46,7 +46,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
|
||||
let ipswCacheFolder = VMStorage.tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
|
||||
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)
|
||||
@@ -106,6 +106,8 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
||||
)
|
||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||
try config.save(toURL: vmDir.configURL)
|
||||
|
||||
// Initialize the virtual machine and its configuration
|
||||
@@ -140,7 +142,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
sema.wait()
|
||||
await withTaskCancellationHandler(operation: {
|
||||
sema.wait()
|
||||
}, onCancel: {
|
||||
sema.signal()
|
||||
})
|
||||
|
||||
if Task.isCancelled {
|
||||
DispatchQueue.main.sync {
|
||||
Task {
|
||||
try await self.virtualMachine.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func craftConfiguration(diskURL: URL, auxStorage: VZMacAuxiliaryStorage, vmConfig: VMConfig) throws -> VZVirtualMachineConfiguration {
|
||||
@@ -173,6 +187,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
]
|
||||
configuration.graphicsDevices = [graphicsDeviceConfiguration]
|
||||
|
||||
// Audio
|
||||
let soundDeviceConfiguration = VZVirtioSoundDeviceConfiguration()
|
||||
soundDeviceConfiguration.streams = [VZVirtioSoundDeviceInputStreamConfiguration(), VZVirtioSoundDeviceOutputStreamConfiguration()]
|
||||
configuration.audioDevices = [soundDeviceConfiguration]
|
||||
|
||||
// Keyboard and mouse
|
||||
configuration.keyboards = [VZUSBKeyboardConfiguration()]
|
||||
configuration.pointingDevices = [VZUSBScreenCoordinatePointingDeviceConfiguration()]
|
||||
@@ -207,7 +226,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
||||
}
|
||||
|
||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, networkDevice: VZNetworkDevice, attachmentWasDisconnectedWithError error: Error) {
|
||||
print("virtual machine's network attachment has been disconnected")
|
||||
print("virtual machine's network attachment \(networkDevice) has been disconnected with error: \(error)")
|
||||
sema.signal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,12 @@ struct VMConfig: Codable {
|
||||
memorySize = memorySizeMin
|
||||
}
|
||||
|
||||
init(fromData: Data) throws {
|
||||
self = try JSONDecoder().decode(VMConfig.self, from: fromData)
|
||||
}
|
||||
|
||||
init(fromURL: URL) throws {
|
||||
let jsonConfigData = try FileHandle.init(forReadingFrom: fromURL).readToEnd()!
|
||||
self = try JSONDecoder().decode(VMConfig.self, from: jsonConfigData)
|
||||
self = try Self(fromData: try Data(contentsOf: fromURL))
|
||||
}
|
||||
|
||||
func save(toURL: URL) throws {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import Compression
|
||||
|
||||
enum OCIError: Error {
|
||||
case ShouldBeExactlyOneLayer
|
||||
case ShouldBeAtLeastOneLayer
|
||||
case FailedToCreateDiskFile
|
||||
}
|
||||
|
||||
extension VMDirectory {
|
||||
private static let bufferSizeBytes = 64 * 1024 * 1024
|
||||
private static let layerLimitBytes = 500 * 1000 * 1000
|
||||
|
||||
private static let configMediaType = "application/vnd.cirruslabs.tart.config.v1"
|
||||
private static let diskMediaType = "application/vnd.cirruslabs.tart.disk.v1"
|
||||
private static let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
||||
|
||||
func pullFromRegistry(registry: Registry, reference: String) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, _) = try await registry.pullManifest(reference: reference)
|
||||
|
||||
return try await pullFromRegistry(registry: registry, manifest: manifest)
|
||||
}
|
||||
|
||||
func pullFromRegistry(registry: Registry, manifest: OCIManifest) async throws {
|
||||
// Pull VM's config file layer and re-serialize it into a config file
|
||||
let configLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.configMediaType
|
||||
}
|
||||
if configLayers.count != 1 {
|
||||
throw OCIError.ShouldBeExactlyOneLayer
|
||||
}
|
||||
let configData = try await registry.pullBlob(configLayers.first!.digest)
|
||||
try VMConfig(fromData: configData).save(toURL: configURL)
|
||||
|
||||
// Pull VM's disk layers and decompress them sequentially into a disk file
|
||||
let diskLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.diskMediaType
|
||||
}
|
||||
if diskLayers.isEmpty {
|
||||
throw OCIError.ShouldBeAtLeastOneLayer
|
||||
}
|
||||
if !FileManager.default.createFile(atPath: diskURL.path, contents: nil) {
|
||||
throw OCIError.FailedToCreateDiskFile
|
||||
}
|
||||
let disk = try FileHandle(forWritingTo: diskURL)
|
||||
let filter = try OutputFilter(.decompress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { data in
|
||||
if let data = data {
|
||||
disk.write(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Progress
|
||||
let diskCompressedSize: Int64 = Int64(diskLayers.map {$0.size}.reduce(0) {$0 + $1})
|
||||
let prettyDiskSize = String(format: "%.1f", Double(diskCompressedSize) / 1_000_000_000.0)
|
||||
defaultLogger.appendNewLine("pulling disk (\(prettyDiskSize) GB compressed)...")
|
||||
let progress = Progress(totalUnitCount: diskCompressedSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
for diskLayer in diskLayers {
|
||||
let diskData = try await registry.pullBlob(diskLayer.digest)
|
||||
try filter.write(diskData)
|
||||
|
||||
// Progress
|
||||
progress.completedUnitCount += Int64(diskLayer.size)
|
||||
}
|
||||
try filter.finalize()
|
||||
try disk.close()
|
||||
|
||||
// Pull VM's NVRAM file layer and store it in an NVRAM file
|
||||
defaultLogger.appendNewLine("pulling NVRAM...")
|
||||
|
||||
let nvramLayers = manifest.layers.filter {
|
||||
$0.mediaType == Self.nvramMediaType
|
||||
}
|
||||
if nvramLayers.count != 1 {
|
||||
throw OCIError.ShouldBeExactlyOneLayer
|
||||
}
|
||||
let nvramData = try await registry.pullBlob(nvramLayers.first!.digest)
|
||||
try nvramData.write(to: nvramURL)
|
||||
}
|
||||
|
||||
func pushToRegistry(registry: Registry, references: [String]) async throws {
|
||||
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)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.configMediaType, size: configJSON.count, digest: configDigest))
|
||||
|
||||
// Progress
|
||||
let diskSize = try FileManager.default.attributesOfItem(atPath: diskURL.path)[.size] as! Int64
|
||||
|
||||
defaultLogger.appendNewLine("pushing disk... this will take a while...")
|
||||
let progress = Progress(totalUnitCount: diskSize)
|
||||
ProgressObserver(progress).log(defaultLogger)
|
||||
|
||||
// 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)
|
||||
|
||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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)
|
||||
layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest))
|
||||
|
||||
// Craft a stub OCI config for Docker Hub compatibility
|
||||
struct OCIConfig: Encodable, Decodable {
|
||||
var architecture: String = "arm64"
|
||||
var os: String = "darwin"
|
||||
}
|
||||
|
||||
let ociConfigJSON = try JSONEncoder().encode(OCIConfig())
|
||||
let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON)
|
||||
let ociConfigDescriptor = Descriptor(size: ociConfigJSON.count, digest: ociConfigDigest)
|
||||
|
||||
// Manifest
|
||||
for reference in references {
|
||||
defaultLogger.appendNewLine("pushing manifest for \(reference)...")
|
||||
|
||||
_ = try await registry.pushManifest(reference: reference, config: ociConfigDescriptor, layers: layers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Progress {
|
||||
func percentage() -> String {
|
||||
String(Int(100 * fractionCompleted)) + "%"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Virtualization
|
||||
|
||||
struct UninitializedVMDirectoryError: Error {
|
||||
}
|
||||
@@ -7,7 +8,6 @@ struct AlreadyInitializedVMDirectoryError: Error {
|
||||
}
|
||||
|
||||
struct VMDirectory {
|
||||
var name: String
|
||||
var baseURL: URL
|
||||
|
||||
var configURL: URL {
|
||||
@@ -20,18 +20,26 @@ struct VMDirectory {
|
||||
baseURL.appendingPathComponent("nvram.bin")
|
||||
}
|
||||
|
||||
var name: String {
|
||||
baseURL.lastPathComponent
|
||||
}
|
||||
|
||||
var initialized: Bool {
|
||||
FileManager.default.fileExists(atPath: configURL.path) &&
|
||||
FileManager.default.fileExists(atPath: diskURL.path) &&
|
||||
FileManager.default.fileExists(atPath: nvramURL.path)
|
||||
}
|
||||
|
||||
func initialize() throws {
|
||||
if initialized {
|
||||
func initialize(overwrite: Bool = false) throws {
|
||||
if !overwrite && initialized {
|
||||
throw AlreadyInitializedVMDirectoryError()
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
try? FileManager.default.removeItem(at: configURL)
|
||||
try? FileManager.default.removeItem(at: diskURL)
|
||||
try? FileManager.default.removeItem(at: nvramURL)
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
@@ -39,7 +47,20 @@ struct VMDirectory {
|
||||
throw UninitializedVMDirectoryError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func clone(to: VMDirectory, generateMAC: Bool) throws {
|
||||
try FileManager.default.copyItem(at: configURL, to: to.configURL)
|
||||
try FileManager.default.copyItem(at: nvramURL, to: to.nvramURL)
|
||||
try FileManager.default.copyItem(at: diskURL, to: to.diskURL)
|
||||
|
||||
// Re-generate MAC address
|
||||
var newVMConfig = try VMConfig(fromURL: to.configURL)
|
||||
if generateMAC {
|
||||
newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered()
|
||||
}
|
||||
try newVMConfig.save(toURL: to.configURL)
|
||||
}
|
||||
|
||||
func resizeDisk(_ sizeGB: UInt8) throws {
|
||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct VMStorage {
|
||||
public static let tartHomeDir: URL = FileManager.default
|
||||
.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".tart", isDirectory: true)
|
||||
|
||||
public static let tartVMsDir: URL = tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
public static let tartCacheDir: URL = tartHomeDir.appendingPathComponent("cache", isDirectory: true)
|
||||
|
||||
func create(_ name: String) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(name: name, baseURL: vmURL(name))
|
||||
|
||||
try vmDir.initialize()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func read(_ name: String) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(name: name, baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
func list() throws -> [URL] {
|
||||
do {
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: VMStorage.tartVMsDir,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: .skipsSubdirectoryDescendants)
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
return []
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
return URL.init(
|
||||
fileURLWithPath: name,
|
||||
isDirectory: true,
|
||||
relativeTo: VMStorage.tartVMsDir)
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func isFileNotFound() -> Bool {
|
||||
return (self as NSError).code == NSFileReadNoSuchFileError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageHelper {
|
||||
static func open(_ name: String) throws -> VMDirectory {
|
||||
try missingVMWrap(name) {
|
||||
if let remoteName = try? RemoteName(name) {
|
||||
return try VMStorageOCI().open(remoteName)
|
||||
} else {
|
||||
return try VMStorageLocal().open(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func delete(_ name: String) throws {
|
||||
try missingVMWrap(name) {
|
||||
if let remoteName = try? RemoteName(name) {
|
||||
try VMStorageOCI().delete(remoteName)
|
||||
} else {
|
||||
try VMStorageLocal().delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func missingVMWrap<R: Any>(_ name: String, closure: () throws -> R) throws -> R {
|
||||
do {
|
||||
return try closure()
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
throw RuntimeError("source VM \"\(name)\" not found, is it listed in \"tart list\"?")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func isFileNotFound() -> Bool {
|
||||
(self as NSError).code == NSFileReadNoSuchFileError
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeError: Error, CustomStringConvertible {
|
||||
let message: String
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
var description: String {
|
||||
message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageLocal {
|
||||
let baseURL: URL = Config.tartHomeDir.appendingPathComponent("vms", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: String) -> URL {
|
||||
baseURL.appendingPathComponent(name, isDirectory: true)
|
||||
}
|
||||
|
||||
func exists(_ name: String) -> Bool {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
|
||||
func open(_ name: String) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func create(_ name: String, overwrite: Bool = false) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.initialize(overwrite: overwrite)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func delete(_ name: String) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
do {
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: .skipsSubdirectoryDescendants).compactMap { url in
|
||||
let vmDir = VMDirectory(baseURL: url)
|
||||
|
||||
if !vmDir.initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (vmDir.name, vmDir)
|
||||
}
|
||||
} catch {
|
||||
if error.isFileNotFound() {
|
||||
return []
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
|
||||
class VMStorageOCI {
|
||||
let baseURL = Config.tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||
|
||||
private func vmURL(_ name: RemoteName) -> URL {
|
||||
baseURL.appendingRemoteName(name)
|
||||
}
|
||||
|
||||
func exists(_ name: RemoteName) -> Bool {
|
||||
VMDirectory(baseURL: vmURL(name)).initialized
|
||||
}
|
||||
|
||||
func open(_ name: RemoteName) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.validate()
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func create(_ name: RemoteName, overwrite: Bool = false) throws -> VMDirectory {
|
||||
let vmDir = VMDirectory(baseURL: vmURL(name))
|
||||
|
||||
try vmDir.initialize(overwrite: overwrite)
|
||||
|
||||
return vmDir
|
||||
}
|
||||
|
||||
func delete(_ name: RemoteName) throws {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
func list() throws -> [(String, VMDirectory)] {
|
||||
var result: [(String, VMDirectory)] = Array()
|
||||
|
||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||
return []
|
||||
}
|
||||
|
||||
for case let foundURL as URL in enumerator {
|
||||
let vmDir = VMDirectory(baseURL: foundURL)
|
||||
|
||||
if !vmDir.initialized {
|
||||
continue
|
||||
}
|
||||
|
||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||
var name: String
|
||||
|
||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
||||
name = parts.joined(separator: ":")
|
||||
} else {
|
||||
name = parts.joined(separator: "@")
|
||||
}
|
||||
|
||||
result.append((name, vmDir))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||
defaultLogger.appendNewLine("pulling manifest...")
|
||||
|
||||
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference)
|
||||
|
||||
// Create directory for manifest's digest
|
||||
var digestName = name
|
||||
digestName.reference = Digest.hash(manifestData)
|
||||
if !exists(digestName) {
|
||||
let vmDir = try create(digestName)
|
||||
try await vmDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||
} else {
|
||||
defaultLogger.appendNewLine("\(digestName.reference) image is already cached! creating a symlink...")
|
||||
}
|
||||
|
||||
// Create directory for reference if it's different
|
||||
if digestName != name {
|
||||
// Overwrite the old symbolic link
|
||||
if FileManager.default.fileExists(atPath: vmURL(name).path) {
|
||||
try FileManager.default.removeItem(at: vmURL(name))
|
||||
}
|
||||
|
||||
try FileManager.default.createSymbolicLink(at: vmURL(name), withDestinationURL: vmURL(digestName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
func appendingRemoteName(_ name: RemoteName) -> URL {
|
||||
var result: URL = self
|
||||
|
||||
for pathComponent in (name.host + "/" + name.namespace + "/" + name.reference).split(separator: "/") {
|
||||
result = result.appendingPathComponent(String(pathComponent))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class DigestTests: XCTestCase {
|
||||
func testEmptyData() throws {
|
||||
let data = Data("".utf8)
|
||||
|
||||
let digest = Digest()
|
||||
digest.update(data)
|
||||
XCTAssertEqual(digest.finalize(), "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
|
||||
XCTAssertEqual(Digest.hash(data), "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
}
|
||||
|
||||
func testNonEmptyData() throws {
|
||||
let data = Data("The quick brown fox jumps over the lazy dog".utf8)
|
||||
|
||||
let digest = Digest()
|
||||
digest.update(data)
|
||||
XCTAssertEqual(digest.finalize(), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
|
||||
|
||||
XCTAssertEqual(Digest.hash(data), "sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class RemoteNameTests: XCTestCase {
|
||||
func testTag() throws {
|
||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "latest")
|
||||
|
||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:latest"))
|
||||
}
|
||||
|
||||
func testComplexTag() throws {
|
||||
let expectedRemoteName = RemoteName(host: "ghcr.io", namespace: "a/b", reference: "1.2.3-RC-1")
|
||||
|
||||
XCTAssertEqual(expectedRemoteName, try RemoteName("ghcr.io/a/b:1.2.3-RC-1"))
|
||||
}
|
||||
|
||||
func testDigest() throws {
|
||||
let expectedRemoteName = RemoteName(
|
||||
host: "ghcr.io",
|
||||
namespace: "a/b",
|
||||
reference: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
)
|
||||
|
||||
XCTAssertEqual(expectedRemoteName,
|
||||
try RemoteName("ghcr.io/a/b@sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"))
|
||||
}
|
||||
|
||||
func testASCIIOnly() throws {
|
||||
// Only ASCII letters are supported
|
||||
XCTAssertEqual(try? RemoteName("touché.fr/a/b:latest"), nil)
|
||||
XCTAssertEqual(try? RemoteName("ghcr.io/tou/ché:latest"), nil)
|
||||
XCTAssertEqual(try? RemoteName("ghcr.io/a/b:touché"), nil)
|
||||
}
|
||||
|
||||
func testLocal() throws {
|
||||
// Local image names (those that don't include a registry) are not supported
|
||||
XCTAssertEqual(try? RemoteName("debian:latest"), nil)
|
||||
}
|
||||
|
||||
func testPort() throws {
|
||||
// Port is included in host
|
||||
XCTAssertEqual(try RemoteName("127.0.0.1:8080/a/b").host, "127.0.0.1:8080")
|
||||
|
||||
// Port must be specified when ":" is used
|
||||
XCTAssertEqual(try? RemoteName("127.0.0.1:/a/b").host, nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class TokenResponseTests: XCTestCase {
|
||||
func testBasic() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.token, "some token")
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(60)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
}
|
||||
|
||||
func testExpirationBasic() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":2}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(tokenResponse.expiresIn, 2)
|
||||
|
||||
let expectedTokenExpiresAtRange = Date()...Date().addingTimeInterval(2)
|
||||
XCTAssertTrue(expectedTokenExpiresAtRange.contains(tokenResponse.tokenExpiresAt))
|
||||
|
||||
XCTAssertTrue(tokenResponse.isValid)
|
||||
_ = XCTWaiter.wait(for: [expectation(description: "Wait 3 seconds for the token to become invalid")], timeout: 2)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
}
|
||||
|
||||
func testExpirationWithIssuedAt() throws {
|
||||
let tokenResponseRaw = Data("{\"token\":\"some token\",\"expires_in\":3600,\"issued_at\":\"1970-01-01T00:00:00Z\"}".utf8)
|
||||
let tokenResponse = try TokenResponse.parse(fromData: tokenResponseRaw)
|
||||
|
||||
XCTAssertEqual(Date(timeIntervalSince1970: 3600), tokenResponse.tokenExpiresAt)
|
||||
XCTAssertFalse(tokenResponse.isValid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class URLAbsolutizationTets: XCTestCase {
|
||||
func testNeedsAbsolutization() throws {
|
||||
let url = URL(string: "/v2/some/path?some=query")!
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
|
||||
XCTAssertEqual(url.absoluteString, "https://example.com/v2/some/path?some=query")
|
||||
}
|
||||
|
||||
func testDoesntNeedAbsolutization() throws {
|
||||
let url = URL(string: "https://example.org/v2/some/path?some=query")!
|
||||
.absolutize(URL(string: "https://example.com/v2/")!)
|
||||
|
||||
XCTAssertEqual(url.absoluteString, "https://example.org/v2/some/path?some=query")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import XCTest
|
||||
@testable import tart
|
||||
|
||||
final class WWWAuthenticateTests: XCTestCase {
|
||||
func testExample() throws {
|
||||
// Test example from Token Authentication Specification[1]
|
||||
//
|
||||
// [1]: https://docs.docker.com/registry/spec/auth/token/
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: "Bearer realm=\"https://auth.docker.io/token\",service=\"registry.docker.io\",scope=\"repository:samalba/my-app:pull,push\"")
|
||||
|
||||
XCTAssertEqual("Bearer", wwwAuthenticate.scheme)
|
||||
XCTAssertEqual([
|
||||
"realm": "https://auth.docker.io/token",
|
||||
"service": "registry.docker.io",
|
||||
"scope": "repository:samalba/my-app:pull,push",
|
||||
], wwwAuthenticate.kvs)
|
||||
}
|
||||
|
||||
func testBasic() throws {
|
||||
let wwwAuthenticate = try WWWAuthenticate(rawHeaderValue: "Bearer a=b,c=\"d\"")
|
||||
|
||||
XCTAssertEqual("Bearer", wwwAuthenticate.scheme)
|
||||
XCTAssertEqual(["a": "b", "c": "d"], wwwAuthenticate.kvs)
|
||||
}
|
||||
|
||||
func testIncompleteHeader() throws {
|
||||
XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Whatever")) {
|
||||
XCTAssertTrue($0 is RegistryError)
|
||||
}
|
||||
|
||||
XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Bearer ")) {
|
||||
XCTAssertTrue($0 is RegistryError)
|
||||
}
|
||||
}
|
||||
|
||||
func testIncompleteDirective() throws {
|
||||
XCTAssertThrowsError(try WWWAuthenticate(rawHeaderValue: "Bearer whatever")) {
|
||||
XCTAssertTrue($0 is RegistryError)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user