Compare commits

...
21 Commits
Author SHA1 Message Date
Nikolay Edigaryev c09cbefdd0 VZVirtualMachineDelegate implementation: show the error details (#330) 2022-11-18 18:51:26 +04:00
Fedor Korotkov 3896728eb9 Added Code Owners (#328)
* Added Code Owners

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

* Set "HOMEBREW_NO_AUTO_UPDATE=1" for virtualenv installation step

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

* Explain why we disable cookies

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

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

* Further abstract away ways to fetch a URLRequest

* No need to cast HTTPURLResponse to HTTPURLResponse

* Fetcher: no need to be a delegate anymore

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

* Update README.md

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

* Update README.md

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

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

Fixes #237

* Update README.md

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

* Remove redundant mention

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

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

Fixes #273

* added a comment
2022-10-17 21:42:25 +04:00
27 changed files with 587 additions and 84 deletions
+23 -3
View File
@@ -1,13 +1,30 @@
task:
name: Test on Ventura
alias: test
persistent_worker:
labels:
name: Mac-Mini-M1
build_script: swift test
test_script: swift test
name: scaleway-m1
test_script:
- swift test
integration_test_script:
# Build Tart
- swift build
- codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
- export PATH=$(pwd)/.build/arm64-apple-macosx/debug:$PATH
# Run integration tests
- cd integration-tests
- HOMEBREW_NO_AUTO_UPDATE=1 brew install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install -r requirements.txt
- pytest --verbose --junit-xml=pytest-junit.xml
pytest_junit_result_artifacts:
path: "integration-tests/pytest-junit.xml"
format: junit
task:
name: Build
alias: build
only_if: $CIRRUS_TAG == ''
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
@@ -19,6 +36,9 @@ task:
task:
name: Release
only_if: $CIRRUS_TAG != ''
depends_on:
- test
- build
macos_instance:
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
env:
+1
View File
@@ -0,0 +1 @@
* @edigaryev @fkorotkov
+68 -31
View File
@@ -1,4 +1,4 @@
![Tart – open source virtualization for your automation needs](Resources/TartSocial.png)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartSocial.png"/>
*Tart* is a virtualization toolset to build, run and manage macOS and Linux virtual machines on Apple Silicon.
Built by CI engineers for your automation needs. Here are some highlights of Tart:
@@ -28,14 +28,42 @@ Try running a Tart VM on your Apple Silicon device running macOS Monterey or lat
```shell
brew install cirruslabs/cli/tart
tart clone ghcr.io/cirruslabs/macos-monterey-base:latest monterey-base
tart run monterey-base
tart clone ghcr.io/cirruslabs/macos-ventura-base:latest ventura-base
tart run ventura-base
```
![tart VM view app](Resources/TartScreenshot.png)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartScreenshot.png"/>
## CI Integration
Tart already powers several CI services mentioned above including our own [Cirrus CI](https://cirrus-ci.org/guide/macOS/) which offers unlimited concurrency with per-second billing.
For services that haven't leveraged Tart yet, we offer fully managed runners via a monthly subscription.
*Cirrus Runners* is the fastest way to get your current CI workflows to benefit from Apple Silicon hardware. No need to manage infrastructure or migrate to another CI provider.
Please read down below about currently supported services.
### Managed runners for your CI-as-a-service
At the moment Cirrus Runners only supports GitHub Actions, but we are actively working on adding more options.
Please [email us](mailto:hello@cirruslabs.org) if you are interested in a particular one.
#### GitHub Actions
Configuring Cirrus Runners for GitHub Actions is as simple as installing [Cirrus Runners App](https://github.com/apps/cirrus-runners).
After successful installation and subscription configuration, use any of [Ventura images managed by us](https://github.com/cirruslabs/macos-image-templates) in `runs-on`:
```yaml
name: Test Suite
jobs:
test:
runs-on: ghcr.io/cirruslabs/macos-ventura-xcode:latest
```
When workflows are executing you'll see Cirrus on-demand runners on your organization's settings page at `https://github.com/organizations/<ORGANIZATION>/settings/actions/runners`.
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartGHARunners.png"/>
### Self-hosted CI
Tart itself is only responsible for managing virtual machines, but we've built Tart support into a tool called Cirrus CLI
also developed by Cirrus Labs. [Cirrus CLI](https://github.com/cirruslabs/cirrus-cli) is a command line tool with
one configuration format to execute common CI steps (run a script, cache a folder, etc.) locally or in any CI system.
@@ -64,14 +92,14 @@ brew install cirruslabs/cli/cirrus
cirrus run
```
![Cirrus CLI Run](Resources/TartCirrusCLI.gif)
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/TartCirrusCLI.gif"/>
[Cirrus CI](https://cirrus-ci.org/) already leverages Tart to power its macOS cloud infrastructure. The `.cirrus.yml`
config from above will just work in Cirrus CI and your tasks will be executed inside Tart VMs in our cloud.
**Note:** Cirrus CI only allows [images managed and regularly updated by us](https://github.com/orgs/cirruslabs/packages?tab=packages&q=macos).
### Retrieving artifacts from within Tart VMs
#### Retrieving artifacts from within Tart VMs
In many cases there is a need to retrieve particular files or a folder from within a Tart virtual machine.
For example, the below `.cirrus.yml` configuration defines a single task that builds a `tart` binary and
@@ -152,32 +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.
Here is an example of a template to build `monterey-base` local image based of a remote image:
```json
{
"builders": [
{
"name": "tart",
"type": "tart-cli",
"vm_base_name": "tartvm/vanilla:latest",
"vm_name": "monterey-base",
"cpu_count": 4,
"memory_gb": 8,
"disk_size_gb": 32,
"ssh_username": "admin",
"ssh_password": "admin",
"ssh_timeout": "120s"
```hcl
packer {
required_plugins {
tart = {
version = ">= 0.5.3"
source = "github.com/cirruslabs/tart"
}
],
"provisioners": [
{
"inline": [
"echo 'Disabling spotlight indexing...'",
"sudo mdutil -a -i off"
],
"type": "shell"
},
# more provisioners
]
}
}
source "tart-cli" "tart" {
vm_base_name = "ghcr.io/cirruslabs/macos-ventura-base:latest"
vm_name = "my-custom-ventura"
cpu_count = 4
memory_gb = 8
disk_size_gb = 70
ssh_password = "admin"
ssh_timeout = "120s"
ssh_username = "admin"
}
build {
sources = ["source.tart-cli.tart"]
provisioner "shell" {
inline = ["echo 'Disabling spotlight indexing...'", "sudo mdutil -a -i off"]
}
# more provisioners
}
```
@@ -197,6 +228,12 @@ tart login acme.io
Credentials are securely stored in Keychain.
In addition, Tart supports [Docker credential helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
if defined in `~/.docker/config.json`.
Finally, `TART_REGISTRY_USERNAME` and `TART_REGISTRY_PASSWORD` environment variables allow to override authorization
for all registries which might useful for integrating with your CI's secret management.
#### Pushing a Local Image
Once credentials are saved for `acme.io`, run the following command to push a local images remotely with two tags:
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:23728bb5438c88b3d0826170a5fa4b7aaad0fbd94a4769c8d492c9f75b57ba81
size 155885
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
size 205325
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
size 1359834
+31
View File
@@ -0,0 +1,31 @@
import ArgumentParser
import Foundation
struct Get: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "get", abstract: "Get a VM's configuration")
@Argument(help: "VM name")
var name: String
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
let diskSize = try vmDir.sizeBytes() / 1000 / 1000 / 1000
print("CPU\tMemory\tDisk\tDisplay")
var s = "\(vmConfig.cpuCount)\t"
s += "\(vmConfig.memorySize / 1024 / 1024) MB\t"
s += "\(diskSize) GB\t"
s += "\(vmConfig.display.width)x\(vmConfig.display.height)"
print(s)
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
+11 -2
View File
@@ -5,9 +5,14 @@ import SwiftUI
struct List: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "List created VMs")
@Flag(name: [.short, .long], help: ArgumentHelp("Only display VM names"))
var quiet: Bool = false
func run() async throws {
do {
print("Source\tName")
if !quiet {
print("Source\tName")
}
displayTable("local", try VMStorageLocal().list())
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
@@ -22,7 +27,11 @@ struct List: AsyncParsableCommand {
private func displayTable(_ source: String, _ vms: [(String, VMDirectory)]) {
for (name, _) in vms.sorted(by: { left, right in left.0 < right.0 }) {
print("\(source)\t\(name)")
if quiet {
print(name)
} else {
print("\(source)\t\(name)")
}
}
}
}
+18 -5
View File
@@ -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 {
do {
if let vncImpl = vncImpl {
@@ -129,11 +147,6 @@ struct Run: AsyncParsableCommand {
Foundation.exit(0)
} catch {
if error.localizedDescription.contains("Failed to lock auxiliary storage.") {
print("Virtual machine \"\(name)\" is already running!")
Foundation.exit(2)
}
print(error)
Foundation.exit(1)
}
+2 -2
View File
@@ -11,7 +11,7 @@ struct Set: AsyncParsableCommand {
var cpu: UInt16?
@Option(help: "VM memory size in megabytes")
var memory: UInt16?
var memory: UInt64?
@Option(help: "VM display resolution in a format of <width>x<height>. For example, 1200x800")
var display: VMDisplayConfig?
@@ -29,7 +29,7 @@ struct Set: AsyncParsableCommand {
}
if let memory = memory {
try vmConfig.setMemory(memorySize: UInt64(memory) * 1024 * 1024)
try vmConfig.setMemory(memorySize: memory * 1024 * 1024)
}
if let display = display {
+72
View File
@@ -0,0 +1,72 @@
import ArgumentParser
import Foundation
import System
import SwiftDate
struct Stop: AsyncParsableCommand {
static var configuration = CommandConfiguration(commandName: "stop", abstract: "Stop a VM")
@Argument(help: "VM name")
var name: String
@Option(name: [.short, .long], help: "Seconds to wait for graceful termination before forcefully terminating the VM")
var timeout: UInt64 = 30
func run() async throws {
do {
let vmDir = try VMStorageLocal().open(name)
let lock = try PIDLock(lockURL: vmDir.configURL)
// Find the VM's PID
var pid = try lock.pid()
if pid == 0 {
print("VM \(name) is not running")
Foundation.exit(2)
}
// Try to gracefully terminate the VM
//
// Note that we don't check the return code here
// to provide a clean exit from "tart stop" in cases
// when the VM is already shutting down and we hit
// a race condition.
//
// We check the return code in the kill(2) below, though,
// because it's a less common scenario and it would be
// nice to know for the user that we've tried all methods
// and failed to shutdown the VM.
kill(pid, SIGINT)
// Ensure that the VM has terminated
var gracefulWaitDuration = Measurement(value: Double(timeout), unit: UnitDuration.seconds)
let gracefulTickDuration = Measurement(value: Double(100), unit: UnitDuration.milliseconds)
while gracefulWaitDuration.value > 0 {
pid = try lock.pid()
if pid == 0 {
Foundation.exit(0)
}
try await Task.sleep(nanoseconds: UInt64(gracefulTickDuration.converted(to: .nanoseconds).value))
gracefulWaitDuration = gracefulWaitDuration - gracefulTickDuration
}
// Seems that VM is still running, proceed with forceful termination
let ret = kill(pid, SIGKILL)
if ret != 0 {
let details = Errno(rawValue: CInt(errno))
print("failed to forcefully terminate the VM \(name): \(details)")
Foundation.exit(1)
}
Foundation.exit(0)
} catch {
print(error)
Foundation.exit(1)
}
}
}
@@ -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 {
}
}
+67
View File
@@ -0,0 +1,67 @@
import Foundation
import AsyncAlgorithms
fileprivate let urlSession = createURLSession()
class Fetcher {
static func fetch(_ request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
if viaFile {
return try await fetchViaFile(request)
}
return try await fetchViaMemory(request)
}
private static func fetchViaMemory(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (data, response) = try await urlSession.data(for: request)
Task {
await dataCh.send(data)
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
private static func fetchViaFile(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
let dataCh = AsyncThrowingChannel<Data, Error>()
let (fileURL, response) = try await urlSession.download(for: request)
// Acquire a handle to the downloaded file and then remove it.
//
// This keeps a working reference to that file, yet we don't
// have to deal with the cleanup any more.
let fh = try FileHandle(forReadingFrom: fileURL)
try FileManager.default.removeItem(at: fileURL)
Task {
while let data = try fh.read(upToCount: 64 * 1024 * 1024) {
await dataCh.send(data)
}
dataCh.finish()
}
return (dataCh, response as! HTTPURLResponse)
}
}
fileprivate func createURLSession() -> URLSession {
let config = URLSessionConfiguration.default
// Harbor expects a CSRF token to be present if the HTTP client
// carries a session cookie between its requests[1] and fails if
// it was not present[2].
//
// To fix that, we disable the automatic cookies carry in URLSession.
//
// [1]: https://github.com/goharbor/harbor/blob/a4c577f9ec4f18396207a5e686433a6ba203d4ef/src/server/middleware/csrf/csrf.go#L78
// [2]: https://github.com/cirruslabs/tart/issues/295
config.httpShouldSetCookies = false
return URLSession(configuration: config)
}
+21 -23
View File
@@ -2,8 +2,6 @@ import Foundation
import Algorithms
import AsyncAlgorithms
let chunkSizeBytes = 1 * 1024 * 1024
enum RegistryError: Error {
case UnexpectedHTTPStatusCode(when: String, code: Int, details: String = "")
case MissingLocationHeader
@@ -31,11 +29,11 @@ extension Data {
}
}
extension URLSession.AsyncBytes {
extension AsyncThrowingChannel<Data, Error> {
func asData() async throws -> Data {
var result = Data()
for try await chunk in chunks(ofCount: chunkSizeBytes) {
for try await chunk in self {
result += chunk
}
@@ -102,7 +100,7 @@ class Registry {
init(urlComponents: URLComponents,
namespace: String,
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
baseURL = urlComponents.url!
self.namespace = namespace
@@ -113,7 +111,7 @@ class Registry {
host: String,
namespace: String,
insecure: Bool = false,
credentialsProviders: [CredentialsProvider] = [DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
credentialsProviders: [CredentialsProvider] = [EnvironmentCredentialsProvider(), DockerConfigCredentialsProvider(), KeychainCredentialsProvider()]
) throws {
let proto = insecure ? "http" : "https"
let baseURLComponents = URLComponents(string: proto + "://" + host + "/v2/")!
@@ -214,8 +212,8 @@ class Registry {
parameters: lastChunk ? ["digest": digest] : [:],
body: chunk
)
let expectedStatus = lastChunk ? HTTPCode.Created.rawValue : HTTPCode.Accepted.rawValue
if response.statusCode != expectedStatus {
// always accept both statuses since AWS ECR is not following specification
if response.statusCode != HTTPCode.Created.rawValue && response.statusCode != HTTPCode.Accepted.rawValue {
throw RegistryError.UnexpectedHTTPStatusCode(when: "streaming blob to \(uploadLocation)",
code: response.statusCode, details: data.asText())
}
@@ -228,14 +226,14 @@ class Registry {
}
public func pullBlob(_ digest: String, handler: (Data) throws -> Void) async throws {
let (bytes, response) = try await bytesRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"))
let (channel, response) = try await channelRequest(.GET, endpointURL("\(namespace)/blobs/\(digest)"), viaFile: true)
if response.statusCode != HTTPCode.Ok.rawValue {
let body = try await bytes.asData().asText()
let body = try await channel.asData().asText()
throw RegistryError.UnexpectedHTTPStatusCode(when: "pulling blob", code: response.statusCode,
details: body)
}
for try await part in bytes.chunks(ofCount: chunkSizeBytes) {
for try await part in channel {
try Task.checkCancellation()
try handler(Data(part))
@@ -256,20 +254,21 @@ class Registry {
body: Data? = nil,
doAuth: Bool = true
) 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)
return (try await bytes.asData(), response)
return (try await channel.asData(), response)
}
private func bytesRequest(
private func channelRequest(
_ method: HTTPMethod,
_ urlComponents: URLComponents,
headers: Dictionary<String, String> = Dictionary(),
parameters: Dictionary<String, String> = Dictionary(),
body: Data? = nil,
doAuth: Bool = true
) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
doAuth: Bool = true,
viaFile: Bool = false
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var urlComponents = urlComponents
if urlComponents.queryItems == nil && !parameters.isEmpty {
@@ -294,14 +293,15 @@ class Registry {
currentAuthToken = nil
}
var (bytes, response) = try await authAwareRequest(request: request)
var (channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
if doAuth && response.statusCode == HTTPCode.Unauthorized.rawValue {
_ = try await channel.asData()
try await auth(response: response)
(bytes, response) = try await authAwareRequest(request: request)
(channel, response) = try await authAwareRequest(request: request, viaFile: viaFile)
}
return (bytes, response)
return (channel, response)
}
private func auth(response: HTTPURLResponse) async throws {
@@ -373,7 +373,7 @@ class Registry {
return nil
}
private func authAwareRequest(request: URLRequest) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
private func authAwareRequest(request: URLRequest, viaFile: Bool = false) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
var request = request
if let token = currentAuthToken {
@@ -381,8 +381,6 @@ class Registry {
request.addValue(value, forHTTPHeaderField: name)
}
let (bytes, response) = try await URLSession.shared.bytes(for: request)
return (bytes, response as! HTTPURLResponse)
return try await Fetcher.fetch(request, viaFile: viaFile)
}
}
+52
View File
@@ -0,0 +1,52 @@
import Foundation
import System
class PIDLock {
let url: URL
let fd: Int32
init(lockURL: URL) throws {
url = lockURL
fd = open(lockURL.path, O_RDWR)
}
deinit {
close(fd)
}
func trylock() throws -> Bool {
let (locked, _) = try lockWrapper(F_SETLK, F_WRLCK, "failed to lock \(url)")
return locked
}
func lock() throws {
_ = try lockWrapper(F_SETLKW, F_WRLCK, "failed to lock \(url)")
}
func unlock() throws {
_ = try lockWrapper(F_SETLK, F_UNLCK, "failed to unlock \(url)")
}
func pid() throws -> pid_t {
let (_, result) = try lockWrapper(F_GETLK, F_RDLCK, "failed to get lock \(url) status")
return result.l_pid
}
func lockWrapper(_ operation: Int32, _ type: Int32, _ message: String) throws -> (Bool, flock) {
var result = flock(l_start: 0, l_len: 0, l_pid: 0, l_type: Int16(type), l_whence: Int16(SEEK_SET))
let ret = fcntl(fd, operation, &result)
if ret != 0 {
if operation == F_SETLK && errno == EAGAIN {
return (false, result)
}
let details = Errno(rawValue: CInt(errno))
throw RuntimeError("\(message): \(details)")
}
return (true, result)
}
}
+4 -4
View File
@@ -24,7 +24,7 @@ let passphrases = [
"act",
"action",
"actor",
" actress",
"actress",
"actual",
"adapt",
"add",
@@ -829,7 +829,7 @@ let passphrases = [
"grow",
"grunt",
"guard",
" guess",
"guess",
"guide",
"guilt",
"guitar",
@@ -1476,7 +1476,7 @@ let passphrases = [
"retire",
"retreat",
"return",
" reunion",
"reunion",
"reveal",
"review",
"reward",
@@ -1764,7 +1764,7 @@ let passphrases = [
"swim",
"swing",
"switch",
" sword",
"sword",
"symbol",
"symptom",
"syrup",
+2
View File
@@ -20,6 +20,7 @@ struct Root: AsyncParsableCommand {
Clone.self,
Run.self,
Set.self,
Get.self,
List.self,
Login.self,
IP.self,
@@ -27,6 +28,7 @@ struct Root: AsyncParsableCommand {
Push.self,
Prune.self,
Rename.self,
Stop.self,
Delete.self,
])
+9 -4
View File
@@ -63,9 +63,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
static func retrieveIPSW(remoteURL: URL) async throws -> URL {
// Check if we already have this IPSW in cache
let (bytes, response) = try await URLSession.shared.bytes(from: remoteURL)
let (channel, response) = try await Fetcher.fetch(URLRequest(url: remoteURL), viaFile: true)
if let hash = (response as! HTTPURLResponse).value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
if let hash = response.value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
if FileManager.default.fileExists(atPath: ipswLocation.path) {
@@ -90,7 +90,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
let fileHandle = try FileHandle(forWritingTo: temporaryLocation)
let digest = Digest()
for try await chunk in bytes.chunks(ofCount: chunkSizeBytes) {
for try await chunk in channel {
let chunkAsData = Data(chunk)
fileHandle.write(chunkAsData)
digest.update(chunkAsData)
@@ -138,6 +138,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
ipswURL = try await VM.retrieveIPSW(remoteURL: ipswURL)
}
// We create a temporary TART_HOME directory in tests, which has its "cache" folder symlinked
// to the users Tart cache directory (~/.tart/cache). However, the Virtualization.Framework
// cannot deal with paths that contain symlinks, so expand them here first.
ipswURL.resolveSymlinksInPath()
// Load the restore image and try to get the requirements
// that match both the image and our platform
let image = try await withCheckedThrowingContinuation { continuation in
@@ -317,7 +322,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
func virtualMachine(_ virtualMachine: VZVirtualMachine, didStopWithError error: Error) {
print("guest has stopped the virtual machine due to error")
print("guest has stopped the virtual machine due to error: \(error)")
sema.signal()
}
+7 -8
View File
@@ -1,12 +1,6 @@
import Foundation
import Virtualization
struct UninitializedVMDirectoryError: Error {
}
struct AlreadyInitializedVMDirectoryError: Error {
}
struct VMDirectory: Prunable {
var baseURL: URL
@@ -47,7 +41,7 @@ struct VMDirectory: Prunable {
func initialize(overwrite: Bool = false) throws {
if !overwrite && initialized {
throw AlreadyInitializedVMDirectoryError()
throw RuntimeError("VM directory is already initialized, preventing overwrite")
}
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true, attributes: nil)
@@ -58,8 +52,13 @@ struct VMDirectory: Prunable {
}
func validate() throws {
if !FileManager.default.fileExists(atPath: baseURL.path) {
throw RuntimeError("the specified VM does not exist")
}
if !initialized {
throw UninitializedVMDirectoryError()
throw RuntimeError("VM is missing some of its files (\(configURL.lastPathComponent),"
+ " \(diskURL.lastPathComponent) or \(nvramURL.lastPathComponent))")
}
}
+16
View File
@@ -0,0 +1,16 @@
import pytest
from tart import Tart
from docker_registry import DockerRegistry
@pytest.fixture(scope="class")
def tart():
with Tart() as tart:
yield tart
@pytest.fixture(scope="class")
def docker_registry():
with DockerRegistry() as docker_registry:
yield docker_registry
+20
View File
@@ -0,0 +1,20 @@
import requests
from testcontainers.core.waiting_utils import wait_container_is_ready
from testcontainers.general import DockerContainer
class DockerRegistry(DockerContainer):
_default_exposed_port = 5000
def __init__(self):
super().__init__("registry:2")
self.with_exposed_ports(self._default_exposed_port)
@wait_container_is_ready(requests.exceptions.ConnectionError)
def remote_name(self, for_vm: str):
exposed_port = self.get_exposed_port(self._default_exposed_port)
requests.get(f"http://127.0.0.1:{exposed_port}/v2/")
return f"127.0.0.1:{exposed_port}/tart/{for_vm}:latest"
+5
View File
@@ -0,0 +1,5 @@
pytest
testcontainers
requests
bitmath
pytest-dependency
+33
View File
@@ -0,0 +1,33 @@
import tempfile
import os
import subprocess
class Tart:
def __init__(self):
self.tmp_dir = tempfile.TemporaryDirectory(dir=os.environ.get("CIRRUS_WORKING_DIR"))
# Link to the users IPSW cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache", "IPSWs")
dst = os.path.join(self.tmp_dir.name, "cache", "IPSWs")
os.makedirs(os.path.join(self.tmp_dir.name, "cache"))
os.symlink(src, dst)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.tmp_dir.cleanup()
def home(self) -> str:
return self.tmp_dir.name
def run(self, args):
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
completed_process = subprocess.run(["tart"] + args, env=env, capture_output=True)
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
+10
View File
@@ -0,0 +1,10 @@
def test_clone(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Clone the VM
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\nubuntu\n"
+16
View File
@@ -0,0 +1,16 @@
def test_create_macos(tart):
# Create a macOS VM
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "macos-vm\n"
def test_create_linux(tart):
# Create a Linux VM
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
assert stdout == "linux-vm\n"
+14
View File
@@ -0,0 +1,14 @@
def test_delete(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == ""
+55
View File
@@ -0,0 +1,55 @@
import os
import tempfile
import timeit
import uuid
import bitmath
import pytest
amount_to_transfer = bitmath.GB(1)
minimal_speed_per_second = bitmath.Mb(100)
class TestOCI:
@pytest.mark.dependency()
def test_push_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["push", "--insecure", vm_with_random_disk, docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@pytest.mark.dependency(depends=["TestOCI::test_push_speed"])
def test_pull_speed(self, tart, vm_with_random_disk, docker_registry):
start = timeit.default_timer()
tart.run(["pull", "--insecure", docker_registry.remote_name(vm_with_random_disk)])
stop = timeit.default_timer()
actual_speed_per_second = self._calculate_speed_per_second(amount_to_transfer, stop - start)
assert actual_speed_per_second > minimal_speed_per_second
@staticmethod
def _calculate_speed_per_second(amount_transferred, time_taken):
return (amount_transferred / time_taken).best_prefix(bitmath.SI)
@pytest.fixture(scope="class")
def vm_with_random_disk(tart):
vm_name = str(uuid.uuid4())
# Create a VM (Linux for speed's sake)
tart.run(["create", "--linux", vm_name])
# Populate VM's disk with "amount_to_transfer" of random bytes
# to effectively disable Tart's OCI blob compression
disk_path = os.path.join(tart.home(), "vms", vm_name, "disk.img")
with tempfile.NamedTemporaryFile(delete=False) as tf:
tf.write(os.urandom(amount_to_transfer.bytes))
tf.close()
os.rename(tf.name, disk_path)
yield vm_name
tart.run(["delete", vm_name])
+10
View File
@@ -0,0 +1,10 @@
def test_rename(tart):
# Create a Linux VM (because we can create it really fast)
tart.run(["create", "--linux", "debian"])
# Rename that VM
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
assert stdout == "ubuntu\n"