mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-21 09:42:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c09cbefdd0 | ||
|
|
3896728eb9 | ||
|
|
de993fbf6d | ||
|
|
f08ddf3855 | ||
|
|
8524d93741 | ||
|
|
833c162187 | ||
|
|
31ba71dad7 | ||
|
|
5e77968989 | ||
|
|
f37372da28 | ||
|
|
e600d2f036 | ||
|
|
b21dbbe3a3 | ||
|
|
ee0fbdd83d | ||
|
|
8961c5189a | ||
|
|
555715588d | ||
|
|
8d096966b4 | ||
|
|
fb954b7cc1 | ||
|
|
8cbcd2285b | ||
|
|
3d0d889c99 | ||
|
|
0e77f14dd7 | ||
|
|
39e1b84423 | ||
|
|
af7530ee50 |
+23
-3
@@ -1,13 +1,30 @@
|
|||||||
task:
|
task:
|
||||||
name: Test on Ventura
|
name: Test on Ventura
|
||||||
|
alias: test
|
||||||
persistent_worker:
|
persistent_worker:
|
||||||
labels:
|
labels:
|
||||||
name: Mac-Mini-M1
|
name: scaleway-m1
|
||||||
build_script: swift test
|
test_script:
|
||||||
test_script: swift test
|
- swift test
|
||||||
|
integration_test_script:
|
||||||
|
# Build Tart
|
||||||
|
- swift build
|
||||||
|
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||||
|
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
|
||||||
|
# Run integration tests
|
||||||
|
- cd integration-tests
|
||||||
|
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
|
||||||
|
- virtualenv venv
|
||||||
|
- source venv/bin/activate
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- pytest --verbose --junit-xml=pytest-junit.xml
|
||||||
|
pytest_junit_result_artifacts:
|
||||||
|
path: "integration-tests/pytest-junit.xml"
|
||||||
|
format: junit
|
||||||
|
|
||||||
task:
|
task:
|
||||||
name: Build
|
name: Build
|
||||||
|
alias: build
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
@@ -19,6 +36,9 @@ task:
|
|||||||
task:
|
task:
|
||||||
name: Release
|
name: Release
|
||||||
only_if: $CIRRUS_TAG != ''
|
only_if: $CIRRUS_TAG != ''
|
||||||
|
depends_on:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
* @edigaryev @fkorotkov
|
||||||
@@ -1,4 +1,4 @@
|
|||||||

|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
|
||||||
|
|
||||||
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
|
||||||
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
Built by CI engineers for your automation needs. Here are some highlights of Tart:
|
||||||
@@ -28,14 +28,42 @@ Try running a Tart VM on your Apple Silicon device running macOS Monterey or lat
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
brew install cirruslabs/cli/tart
|
brew install cirruslabs/cli/tart
|
||||||
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
|
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
|
||||||
tart run monterey-base
|
tart run ventura-base
|
||||||
```
|
```
|
||||||
|
|
||||||

|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
|
||||||
|
|
||||||
## CI Integration
|
## CI Integration
|
||||||
|
|
||||||
|
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
|
||||||
|
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
|
||||||
|
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
|
||||||
|
Please read down below about currently supported services.
|
||||||
|
|
||||||
|
### Managed runners for your CI-as-a-service
|
||||||
|
|
||||||
|
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
|
||||||
|
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
|
||||||
|
|
||||||
|
#### GitHub Actions
|
||||||
|
|
||||||
|
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
|
||||||
|
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Test Suite
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
|
||||||
|
|
||||||
|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
|
||||||
|
|
||||||
|
### Self-hosted CI
|
||||||
|
|
||||||
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
|
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
|
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.
|
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
|
||||||
@@ -64,14 +92,14 @@ brew install cirruslabs/cli/cirrus
|
|||||||
cirrus run
|
cirrus run
|
||||||
```
|
```
|
||||||
|
|
||||||

|
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
|
||||||
|
|
||||||
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
|
[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.
|
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).
|
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
|
||||||
|
|
||||||
### Retrieving artifacts from within Tart VMs
|
#### Retrieving artifacts from within Tart VMs
|
||||||
|
|
||||||
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
|
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
|
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
|
||||||
@@ -152,32 +180,35 @@ Please refer to `tart set --help` for additional details.
|
|||||||
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
Please refer to [Tart Packer Plugin repository](https://github.com/cirruslabs/packer-plugin-tart) for setup instructions.
|
||||||
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
Here is an example of a template to build `monterey-base` local image based of a remote image:
|
||||||
|
|
||||||
```json
|
```hcl
|
||||||
{
|
packer {
|
||||||
"builders": [
|
required_plugins {
|
||||||
{
|
tart = {
|
||||||
"name": "tart",
|
version = ">= 0.5.3"
|
||||||
"type": "tart-cli",
|
source = "github.com/cirruslabs/tart"
|
||||||
"vm_base_name": "tartvm/vanilla:latest",
|
|
||||||
"vm_name": "monterey-base",
|
|
||||||
"cpu_count": 4,
|
|
||||||
"memory_gb": 8,
|
|
||||||
"disk_size_gb": 32,
|
|
||||||
"ssh_username": "admin",
|
|
||||||
"ssh_password": "admin",
|
|
||||||
"ssh_timeout": "120s"
|
|
||||||
}
|
}
|
||||||
],
|
}
|
||||||
"provisioners": [
|
}
|
||||||
{
|
|
||||||
"inline": [
|
source "tart-cli" "tart" {
|
||||||
"echo 'Disabling spotlight indexing...'",
|
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
|
||||||
"sudo mdutil -a -i off"
|
vm_name = "my-custom-ventura"
|
||||||
],
|
cpu_count = 4
|
||||||
"type": "shell"
|
memory_gb = 8
|
||||||
},
|
disk_size_gb = 70
|
||||||
# more provisioners
|
ssh_password = "admin"
|
||||||
]
|
ssh_timeout = "120s"
|
||||||
|
ssh_username = "admin"
|
||||||
|
}
|
||||||
|
|
||||||
|
build {
|
||||||
|
sources = ["source.tart-cli.tart"]
|
||||||
|
|
||||||
|
provisioner "shell" {
|
||||||
|
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# more provisioners
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,6 +228,12 @@ tart login acme.io
|
|||||||
|
|
||||||
Credentials are securely stored in Keychain.
|
Credentials are securely stored in Keychain.
|
||||||
|
|
||||||
|
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
|
||||||
|
if defined in `~/.docker/config.json`.
|
||||||
|
|
||||||
|
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
|
||||||
|
for all registries which might useful for integrating with your CI's secret management.
|
||||||
|
|
||||||
#### Pushing a Local Image
|
#### Pushing a Local Image
|
||||||
|
|
||||||
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
|
||||||
|
size 155885
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
|
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
|
||||||
size 205325
|
size 1359834
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Get: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
|
||||||
|
|
||||||
|
@Argument(help: "VM name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
do {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
|
||||||
|
|
||||||
|
print("CPU\tMemory\tDisk\tDisplay")
|
||||||
|
|
||||||
|
var s = "\(vmConfig.cpuCount)\t"
|
||||||
|
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
|
||||||
|
s += "\(diskSize) GB\t"
|
||||||
|
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
|
||||||
|
print(s)
|
||||||
|
|
||||||
|
Foundation.exit(0)
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,14 @@ import SwiftUI
|
|||||||
struct List: AsyncParsableCommand {
|
struct List: AsyncParsableCommand {
|
||||||
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
static var configuration = CommandConfiguration(abstract: "List created VMs")
|
||||||
|
|
||||||
|
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
|
||||||
|
var quiet: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
print("Source\tName")
|
if !quiet {
|
||||||
|
print("Source\tName")
|
||||||
|
}
|
||||||
|
|
||||||
displayTable("local", try VMStorageLocal().list())
|
displayTable("local", try VMStorageLocal().list())
|
||||||
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||||
@@ -22,7 +27,11 @@ struct List: AsyncParsableCommand {
|
|||||||
|
|
||||||
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
|
||||||
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
|
||||||
print("\(source)\t\(name)")
|
if quiet {
|
||||||
|
print(name)
|
||||||
|
} else {
|
||||||
|
print("\(source)\t\(name)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,24 @@ struct Run: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Lock the VM
|
||||||
|
//
|
||||||
|
// More specifically, lock the "config.json", because we can't lock
|
||||||
|
// directories with fcntl(2)-based locking and we better not interfere
|
||||||
|
// with the VM's disk and NVRAM, because they are opened (and even seem
|
||||||
|
// to be locked) directly by the Virtualization.Framework's process.
|
||||||
|
//
|
||||||
|
// Note that due to "completely stupid semantics"[1] of the fcntl-based
|
||||||
|
// file locking, we need to acquire the lock after we read the VM's
|
||||||
|
// configuration file, otherwise we will loose the lock.
|
||||||
|
//
|
||||||
|
// [1]: https://man.openbsd.org/fcntl
|
||||||
|
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||||
|
if try !lock.trylock() {
|
||||||
|
print("Virtual machine \"\(name)\" is already running!")
|
||||||
|
Foundation.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
let task = Task {
|
let task = Task {
|
||||||
do {
|
do {
|
||||||
if let vncImpl = vncImpl {
|
if let vncImpl = vncImpl {
|
||||||
@@ -129,11 +147,6 @@ struct Run: AsyncParsableCommand {
|
|||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
|
|
||||||
print("Virtual machine \"\(name)\" is already running!")
|
|
||||||
Foundation.exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
print(error)
|
print(error)
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
var cpu: UInt16?
|
var cpu: UInt16?
|
||||||
|
|
||||||
@Option(help: "VM memory size in megabytes")
|
@Option(help: "VM memory size in megabytes")
|
||||||
var memory: UInt16?
|
var memory: UInt64?
|
||||||
|
|
||||||
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
|
||||||
var display: VMDisplayConfig?
|
var display: VMDisplayConfig?
|
||||||
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let memory = memory {
|
if let memory = memory {
|
||||||
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
|
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let display = display {
|
if let display = display {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
import SwiftDate
|
||||||
|
|
||||||
|
struct Stop: AsyncParsableCommand {
|
||||||
|
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
|
||||||
|
|
||||||
|
@Argument(help: "VM name")
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
|
||||||
|
var timeout: UInt64 = 30
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
do {
|
||||||
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
|
let lock = try PIDLock(lockURL: vmDir.configURL)
|
||||||
|
|
||||||
|
// Find the VM's PID
|
||||||
|
var pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
print("VM \(name) is not running")
|
||||||
|
|
||||||
|
Foundation.exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to gracefully terminate the VM
|
||||||
|
//
|
||||||
|
// Note that we don't check the return code here
|
||||||
|
// to provide a clean exit from "tart stop" in cases
|
||||||
|
// when the VM is already shutting down and we hit
|
||||||
|
// a race condition.
|
||||||
|
//
|
||||||
|
// We check the return code in the kill(2) below, though,
|
||||||
|
// because it's a less common scenario and it would be
|
||||||
|
// nice to know for the user that we've tried all methods
|
||||||
|
// and failed to shutdown the VM.
|
||||||
|
kill(pid, SIGINT)
|
||||||
|
|
||||||
|
// Ensure that the VM has terminated
|
||||||
|
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
|
||||||
|
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
|
||||||
|
|
||||||
|
while gracefulWaitDuration.value > 0 {
|
||||||
|
pid = try lock.pid()
|
||||||
|
if pid == 0 {
|
||||||
|
Foundation.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
|
||||||
|
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seems that VM is still running, proceed with forceful termination
|
||||||
|
let ret = kill(pid, SIGKILL)
|
||||||
|
if ret != 0 {
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
print("failed to forcefully terminate the VM \(name): \(details)")
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Foundation.exit(0)
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
|
||||||
|
Foundation.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class EnvironmentCredentialsProvider: CredentialsProvider {
|
||||||
|
func retrieve(host: String) throws -> (String, String)? {
|
||||||
|
let username = ProcessInfo.processInfo.environment["TART_REGISTRY_USERNAME"]
|
||||||
|
let password = ProcessInfo.processInfo.environment["TART_REGISTRY_PASSWORD"]
|
||||||
|
if let username = username, let password = password {
|
||||||
|
return (username, password)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func store(host: String, user: String, password: String) throws {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
import AsyncAlgorithms
|
||||||
|
|
||||||
|
fileprivate let urlSession = createURLSession()
|
||||||
|
|
||||||
|
class Fetcher {
|
||||||
|
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
if viaFile {
|
||||||
|
return try await fetchViaFile(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await fetchViaMemory(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
|
let (data, response) = try await urlSession.data(for: request)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
await dataCh.send(data)
|
||||||
|
|
||||||
|
dataCh.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
|
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||||
|
|
||||||
|
let (fileURL, response) = try await urlSession.download(for: request)
|
||||||
|
|
||||||
|
// Acquire a handle to the downloaded file and then remove it.
|
||||||
|
//
|
||||||
|
// This keeps a working reference to that file, yet we don't
|
||||||
|
// have to deal with the cleanup any more.
|
||||||
|
let fh = try FileHandle(forReadingFrom: fileURL)
|
||||||
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
|
||||||
|
await dataCh.send(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataCh.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (dataCh, response as! HTTPURLResponse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func createURLSession() -> URLSession {
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
|
||||||
|
// Harbor expects a CSRF token to be present if the HTTP client
|
||||||
|
// carries a session cookie between its requests[1] and fails if
|
||||||
|
// it was not present[2].
|
||||||
|
//
|
||||||
|
// To fix that, we disable the automatic cookies carry in URLSession.
|
||||||
|
//
|
||||||
|
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
|
||||||
|
// [2]: https://github.com/cirruslabs/tart/issues/295
|
||||||
|
config.httpShouldSetCookies = false
|
||||||
|
|
||||||
|
return URLSession(configuration: config)
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@ import Foundation
|
|||||||
import Algorithms
|
import Algorithms
|
||||||
import AsyncAlgorithms
|
import AsyncAlgorithms
|
||||||
|
|
||||||
let chunkSizeBytes = 1 * 1024 * 1024
|
|
||||||
|
|
||||||
enum RegistryError: Error {
|
enum RegistryError: Error {
|
||||||
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
|
||||||
case MissingLocationHeader
|
case MissingLocationHeader
|
||||||
@@ -31,11 +29,11 @@ extension Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension URLSession.AsyncBytes {
|
extension AsyncThrowingChannel<Data, Error> {
|
||||||
func asData() async throws -> Data {
|
func asData() async throws -> Data {
|
||||||
var result = Data()
|
var result = Data()
|
||||||
|
|
||||||
for try await chunk in chunks(ofCount: chunkSizeBytes) {
|
for try await chunk in self {
|
||||||
result += chunk
|
result += chunk
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +100,7 @@ class Registry {
|
|||||||
|
|
||||||
init(urlComponents: URLComponents,
|
init(urlComponents: URLComponents,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
baseURL = urlComponents.url!
|
baseURL = urlComponents.url!
|
||||||
self.namespace = namespace
|
self.namespace = namespace
|
||||||
@@ -113,7 +111,7 @@ class Registry {
|
|||||||
host: String,
|
host: String,
|
||||||
namespace: String,
|
namespace: String,
|
||||||
insecure: Bool = false,
|
insecure: Bool = false,
|
||||||
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
|
||||||
) throws {
|
) throws {
|
||||||
let proto = insecure ? "http" : "https"
|
let proto = insecure ? "http" : "https"
|
||||||
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
|
||||||
@@ -214,8 +212,8 @@ class Registry {
|
|||||||
parameters: lastChunk ? ["digest": digest] : [:],
|
parameters: lastChunk ? ["digest": digest] : [:],
|
||||||
body: chunk
|
body: chunk
|
||||||
)
|
)
|
||||||
let expectedStatus = lastChunk ? HTTPCode.Created.rawValue : HTTPCode.Accepted.rawValue
|
// always accept both statuses since AWS ECR is not following specification
|
||||||
if response.statusCode != expectedStatus {
|
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
|
||||||
code: response.statusCode, details: data.asText())
|
code: response.statusCode, details: data.asText())
|
||||||
}
|
}
|
||||||
@@ -228,14 +226,14 @@ class Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
|
||||||
let (bytes, response) = try await bytesRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"))
|
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
|
||||||
if response.statusCode != HTTPCode.Ok.rawValue {
|
if response.statusCode != HTTPCode.Ok.rawValue {
|
||||||
let body = try await bytes.asData().asText()
|
let body = try await channel.asData().asText()
|
||||||
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
|
||||||
details: body)
|
details: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
for try await part in bytes.chunks(ofCount: chunkSizeBytes) {
|
for try await part in channel {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
|
|
||||||
try handler(Data(part))
|
try handler(Data(part))
|
||||||
@@ -256,20 +254,21 @@ class Registry {
|
|||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true
|
||||||
) async throws -> (Data, HTTPURLResponse) {
|
) async throws -> (Data, HTTPURLResponse) {
|
||||||
let (bytes, response) = try await bytesRequest(method, urlComponents,
|
let (channel, response) = try await channelRequest(method, urlComponents,
|
||||||
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
headers: headers, parameters: parameters, body: body, doAuth: doAuth)
|
||||||
|
|
||||||
return (try await bytes.asData(), response)
|
return (try await channel.asData(), response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bytesRequest(
|
private func channelRequest(
|
||||||
_ method: HTTPMethod,
|
_ method: HTTPMethod,
|
||||||
_ urlComponents: URLComponents,
|
_ urlComponents: URLComponents,
|
||||||
headers: Dictionary<String, String> = Dictionary(),
|
headers: Dictionary<String, String> = Dictionary(),
|
||||||
parameters: Dictionary<String, String> = Dictionary(),
|
parameters: Dictionary<String, String> = Dictionary(),
|
||||||
body: Data? = nil,
|
body: Data? = nil,
|
||||||
doAuth: Bool = true
|
doAuth: Bool = true,
|
||||||
) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
|
viaFile: Bool = false
|
||||||
|
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var urlComponents = urlComponents
|
var urlComponents = urlComponents
|
||||||
|
|
||||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||||
@@ -294,14 +293,15 @@ class Registry {
|
|||||||
currentAuthToken = nil
|
currentAuthToken = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var (bytes, response) = try await authAwareRequest(request: request)
|
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
|
|
||||||
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
|
||||||
|
_ = try await channel.asData()
|
||||||
try await auth(response: response)
|
try await auth(response: response)
|
||||||
(bytes, response) = try await authAwareRequest(request: request)
|
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (bytes, response)
|
return (channel, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func auth(response: HTTPURLResponse) async throws {
|
private func auth(response: HTTPURLResponse) async throws {
|
||||||
@@ -373,7 +373,7 @@ class Registry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func authAwareRequest(request: URLRequest) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
|
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||||
var request = request
|
var request = request
|
||||||
|
|
||||||
if let token = currentAuthToken {
|
if let token = currentAuthToken {
|
||||||
@@ -381,8 +381,6 @@ class Registry {
|
|||||||
request.addValue(value, forHTTPHeaderField: name)
|
request.addValue(value, forHTTPHeaderField: name)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
return try await Fetcher.fetch(request, viaFile: viaFile)
|
||||||
|
|
||||||
return (bytes, response as! HTTPURLResponse)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
import System
|
||||||
|
|
||||||
|
class PIDLock {
|
||||||
|
let url: URL
|
||||||
|
let fd: Int32
|
||||||
|
|
||||||
|
init(lockURL: URL) throws {
|
||||||
|
url = lockURL
|
||||||
|
fd = open(lockURL.path, O_RDWR)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func trylock() throws -> Bool {
|
||||||
|
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
|
||||||
|
return locked
|
||||||
|
}
|
||||||
|
|
||||||
|
func lock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlock() throws {
|
||||||
|
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pid() throws -> pid_t {
|
||||||
|
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
|
||||||
|
|
||||||
|
return result.l_pid
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
|
||||||
|
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
|
||||||
|
|
||||||
|
let ret = fcntl(fd, operation, &result)
|
||||||
|
if ret != 0 {
|
||||||
|
if operation == F_SETLK && errno == EAGAIN {
|
||||||
|
return (false, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
let details = Errno(rawValue: CInt(errno))
|
||||||
|
|
||||||
|
throw RuntimeError("\(message): \(details)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (true, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ let passphrases = [
|
|||||||
"act",
|
"act",
|
||||||
"action",
|
"action",
|
||||||
"actor",
|
"actor",
|
||||||
" actress",
|
"actress",
|
||||||
"actual",
|
"actual",
|
||||||
"adapt",
|
"adapt",
|
||||||
"add",
|
"add",
|
||||||
@@ -829,7 +829,7 @@ let passphrases = [
|
|||||||
"grow",
|
"grow",
|
||||||
"grunt",
|
"grunt",
|
||||||
"guard",
|
"guard",
|
||||||
" guess",
|
"guess",
|
||||||
"guide",
|
"guide",
|
||||||
"guilt",
|
"guilt",
|
||||||
"guitar",
|
"guitar",
|
||||||
@@ -1476,7 +1476,7 @@ let passphrases = [
|
|||||||
"retire",
|
"retire",
|
||||||
"retreat",
|
"retreat",
|
||||||
"return",
|
"return",
|
||||||
" reunion",
|
"reunion",
|
||||||
"reveal",
|
"reveal",
|
||||||
"review",
|
"review",
|
||||||
"reward",
|
"reward",
|
||||||
@@ -1764,7 +1764,7 @@ let passphrases = [
|
|||||||
"swim",
|
"swim",
|
||||||
"swing",
|
"swing",
|
||||||
"switch",
|
"switch",
|
||||||
" sword",
|
"sword",
|
||||||
"symbol",
|
"symbol",
|
||||||
"symptom",
|
"symptom",
|
||||||
"syrup",
|
"syrup",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Clone.self,
|
Clone.self,
|
||||||
Run.self,
|
Run.self,
|
||||||
Set.self,
|
Set.self,
|
||||||
|
Get.self,
|
||||||
List.self,
|
List.self,
|
||||||
Login.self,
|
Login.self,
|
||||||
IP.self,
|
IP.self,
|
||||||
@@ -27,6 +28,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
Push.self,
|
Push.self,
|
||||||
Prune.self,
|
Prune.self,
|
||||||
Rename.self,
|
Rename.self,
|
||||||
|
Stop.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
|
|
||||||
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
|
||||||
// Check if we already have this IPSW in cache
|
// Check if we already have this IPSW in cache
|
||||||
let (bytes, response) = try await URLSession.shared.bytes(from: remoteURL)
|
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
|
||||||
|
|
||||||
if let hash = (response as! HTTPURLResponse).value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
if FileManager.default.fileExists(atPath: ipswLocation.path) {
|
||||||
@@ -90,7 +90,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
|
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
|
||||||
let digest = Digest()
|
let digest = Digest()
|
||||||
|
|
||||||
for try await chunk in bytes.chunks(ofCount: chunkSizeBytes) {
|
for try await chunk in channel {
|
||||||
let chunkAsData = Data(chunk)
|
let chunkAsData = Data(chunk)
|
||||||
fileHandle.write(chunkAsData)
|
fileHandle.write(chunkAsData)
|
||||||
digest.update(chunkAsData)
|
digest.update(chunkAsData)
|
||||||
@@ -138,6 +138,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
|
||||||
|
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
|
||||||
|
// cannot deal with paths that contain symlinks, so expand them here first.
|
||||||
|
ipswURL.resolveSymlinksInPath()
|
||||||
|
|
||||||
// Load the restore image and try to get the requirements
|
// Load the restore image and try to get the requirements
|
||||||
// that match both the image and our platform
|
// that match both the image and our platform
|
||||||
let image = try await withCheckedThrowingContinuation { continuation in
|
let image = try await withCheckedThrowingContinuation { continuation in
|
||||||
@@ -317,7 +322,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
|
||||||
print("guest has stopped the virtual machine due to error")
|
print("guest has stopped the virtual machine due to error: \(error)")
|
||||||
sema.signal()
|
sema.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
|
|
||||||
struct UninitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AlreadyInitializedVMDirectoryError: Error {
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
@@ -47,7 +41,7 @@ struct VMDirectory: Prunable {
|
|||||||
|
|
||||||
func initialize(overwrite: Bool = false) throws {
|
func initialize(overwrite: Bool = false) throws {
|
||||||
if !overwrite && initialized {
|
if !overwrite && initialized {
|
||||||
throw AlreadyInitializedVMDirectoryError()
|
throw RuntimeError("VM directory is already initialized, preventing overwrite")
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -58,8 +52,13 @@ struct VMDirectory: Prunable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
|
if !FileManager.default.fileExists(atPath: baseURL.path) {
|
||||||
|
throw RuntimeError("the specified VM does not exist")
|
||||||
|
}
|
||||||
|
|
||||||
if !initialized {
|
if !initialized {
|
||||||
throw UninitializedVMDirectoryError()
|
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
|
||||||
|
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from tart import Tart
|
||||||
|
from docker_registry import DockerRegistry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def tart():
|
||||||
|
with Tart() as tart:
|
||||||
|
yield tart
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def docker_registry():
|
||||||
|
with DockerRegistry() as docker_registry:
|
||||||
|
yield docker_registry
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import requests
|
||||||
|
|
||||||
|
from testcontainers.core.waiting_utils import wait_container_is_ready
|
||||||
|
from testcontainers.general import DockerContainer
|
||||||
|
|
||||||
|
|
||||||
|
class DockerRegistry(DockerContainer):
|
||||||
|
_default_exposed_port = 5000
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("registry:2")
|
||||||
|
self.with_exposed_ports(self._default_exposed_port)
|
||||||
|
|
||||||
|
@wait_container_is_ready(requests.exceptions.ConnectionError)
|
||||||
|
def remote_name(self, for_vm: str):
|
||||||
|
exposed_port = self.get_exposed_port(self._default_exposed_port)
|
||||||
|
|
||||||
|
requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
|
||||||
|
|
||||||
|
return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
pytest
|
||||||
|
testcontainers
|
||||||
|
requests
|
||||||
|
bitmath
|
||||||
|
pytest-dependency
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
class Tart:
|
||||||
|
def __init__(self):
|
||||||
|
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
|
||||||
|
|
||||||
|
# Link to the users IPSW cache to make things faster
|
||||||
|
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
|
||||||
|
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
|
||||||
|
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
|
||||||
|
os.symlink(src, dst)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
self.tmp_dir.cleanup()
|
||||||
|
|
||||||
|
def home(self) -> str:
|
||||||
|
return self.tmp_dir.name
|
||||||
|
|
||||||
|
def run(self, args):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({"TART_HOME": self.tmp_dir.name})
|
||||||
|
|
||||||
|
completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
|
||||||
|
|
||||||
|
completed_process.check_returncode()
|
||||||
|
|
||||||
|
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
def test_clone(tart):
|
||||||
|
# Create a Linux VM (because we can create it really fast)
|
||||||
|
tart.run(["create", "--linux", "debian"])
|
||||||
|
|
||||||
|
# Clone the VM
|
||||||
|
tart.run(["clone", "debian", "ubuntu"])
|
||||||
|
|
||||||
|
# Ensure that we have now 2 VMs
|
||||||
|
stdout, _, = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == "debian\nubuntu\n"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
def test_create_macos(tart):
|
||||||
|
# Create a macOS VM
|
||||||
|
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
|
||||||
|
|
||||||
|
# Ensure that the VM was created
|
||||||
|
stdout, _ = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == "macos-vm\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_linux(tart):
|
||||||
|
# Create a Linux VM
|
||||||
|
tart.run(["create", "--linux", "linux-vm"])
|
||||||
|
|
||||||
|
# Ensure that the VM was created
|
||||||
|
stdout, _ = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == "linux-vm\n"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
def test_delete(tart):
|
||||||
|
# Create a Linux VM (because we can create it really fast)
|
||||||
|
tart.run(["create", "--linux", "debian"])
|
||||||
|
|
||||||
|
# Ensure that the VM exists
|
||||||
|
stdout, _, = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == "debian\n"
|
||||||
|
|
||||||
|
# Delete the VM
|
||||||
|
tart.run(["delete", "debian"])
|
||||||
|
|
||||||
|
# Ensure that the VM was removed
|
||||||
|
stdout, _, = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == ""
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import timeit
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import bitmath
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
amount_to_transfer = bitmath.GB(1)
|
||||||
|
minimal_speed_per_second = bitmath.Mb(100)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOCI:
|
||||||
|
@pytest.mark.dependency()
|
||||||
|
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
|
||||||
|
start = timeit.default_timer()
|
||||||
|
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
|
||||||
|
stop = timeit.default_timer()
|
||||||
|
|
||||||
|
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
|
||||||
|
assert actual_speed_per_second > minimal_speed_per_second
|
||||||
|
|
||||||
|
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
|
||||||
|
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
|
||||||
|
start = timeit.default_timer()
|
||||||
|
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
|
||||||
|
stop = timeit.default_timer()
|
||||||
|
|
||||||
|
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
|
||||||
|
assert actual_speed_per_second > minimal_speed_per_second
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calculate_speed_per_second(amount_transferred, time_taken):
|
||||||
|
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="class")
|
||||||
|
def vm_with_random_disk(tart):
|
||||||
|
vm_name = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Create a VM (Linux for speed's sake)
|
||||||
|
tart.run(["create", "--linux", vm_name])
|
||||||
|
|
||||||
|
# Populate VM's disk with "amount_to_transfer" of random bytes
|
||||||
|
# to effectively disable Tart's OCI blob compression
|
||||||
|
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as tf:
|
||||||
|
tf.write(os.urandom(amount_to_transfer.bytes))
|
||||||
|
tf.close()
|
||||||
|
os.rename(tf.name, disk_path)
|
||||||
|
|
||||||
|
yield vm_name
|
||||||
|
|
||||||
|
tart.run(["delete", vm_name])
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
def test_rename(tart):
|
||||||
|
# Create a Linux VM (because we can create it really fast)
|
||||||
|
tart.run(["create", "--linux", "debian"])
|
||||||
|
|
||||||
|
# Rename that VM
|
||||||
|
tart.run(["rename", "debian", "ubuntu"])
|
||||||
|
|
||||||
|
# Ensure that the VM is now named "ubuntu"
|
||||||
|
stdout, _, = tart.run(["list", "--quiet"])
|
||||||
|
assert stdout == "ubuntu\n"
|
||||||
Reference in New Issue
Block a user