mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-10 16:05:35 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb954b7cc1 | ||
|
|
8cbcd2285b | ||
|
|
3d0d889c99 | ||
|
|
0e77f14dd7 | ||
|
|
39e1b84423 | ||
|
|
af7530ee50 |
@@ -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
|
||||
```
|
||||
|
||||

|
||||
<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
|
||||
```
|
||||
|
||||

|
||||
<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
|
||||
|
||||
@@ -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
|
||||
oid sha256:3a43f541b1ab0b57ae2060d371cba5dbb1f5c80b89c76434b7154d8144f66e61
|
||||
size 205325
|
||||
oid sha256:7a4929ca4e02d4968904749028ada7072704d2a52cccb69335e1596452c822c7
|
||||
size 1359834
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import AsyncAlgorithms
|
||||
|
||||
class Fetcher: NSObject, URLSessionTaskDelegate, URLSessionDelegate, URLSessionDataDelegate {
|
||||
let responseCh = AsyncThrowingChannel<URLResponse, Error>()
|
||||
let dataCh = AsyncThrowingChannel<Data, Error>()
|
||||
|
||||
func fetch(_ request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, URLResponse) {
|
||||
let task = URLSession.shared.dataTask(with: request)
|
||||
task.delegate = self
|
||||
task.resume()
|
||||
|
||||
// Wait for the response and only then return
|
||||
var iter = responseCh.makeAsyncIterator()
|
||||
let response = try await iter.next()!
|
||||
|
||||
return (dataCh, response)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition {
|
||||
await responseCh.send(response)
|
||||
|
||||
return .allow
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
||||
let sema = DispatchSemaphore(value: 0)
|
||||
|
||||
Task {
|
||||
await dataCh.send(data)
|
||||
sema.signal()
|
||||
}
|
||||
|
||||
sema.wait()
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
if let error = error {
|
||||
// Premature termination
|
||||
responseCh.fail(error)
|
||||
dataCh.fail(error)
|
||||
} else {
|
||||
dataCh.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"))
|
||||
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,20 @@ 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) {
|
||||
) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var urlComponents = urlComponents
|
||||
|
||||
if urlComponents.queryItems == nil && !parameters.isEmpty {
|
||||
@@ -294,14 +292,15 @@ class Registry {
|
||||
currentAuthToken = nil
|
||||
}
|
||||
|
||||
var (bytes, response) = try await authAwareRequest(request: request)
|
||||
var (channel, response) = try await authAwareRequest(request: request)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
return (bytes, response)
|
||||
return (channel, response)
|
||||
}
|
||||
|
||||
private func auth(response: HTTPURLResponse) async throws {
|
||||
@@ -373,7 +372,7 @@ class Registry {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func authAwareRequest(request: URLRequest) async throws -> (URLSession.AsyncBytes, HTTPURLResponse) {
|
||||
private func authAwareRequest(request: URLRequest) async throws -> (AsyncThrowingChannel<Data, Error>, HTTPURLResponse) {
|
||||
var request = request
|
||||
|
||||
if let token = currentAuthToken {
|
||||
@@ -381,8 +380,8 @@ class Registry {
|
||||
request.addValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
let (channel, response) = try await Fetcher().fetch(request)
|
||||
|
||||
return (bytes, response as! HTTPURLResponse)
|
||||
return (channel, response as! HTTPURLResponse)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,7 +63,7 @@ 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))
|
||||
|
||||
if let hash = (response as! HTTPURLResponse).value(forHTTPHeaderField: "x-amz-meta-digest-sha256") {
|
||||
let ipswLocation = try IPSWCache().locationFor(fileName: "sha256:\(hash).ipsw")
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user