Compare commits

..
20 Commits
Author SHA1 Message Date
Fedor Korotkov 4bb248e7b4 Support wildcards in credHelpers (#592)
* Support wildcards in `credHelpers`

With #591 `tart pull` fails when for example you have `ecr-login` set as the default `credsStore` but you try to pull our images from `ghcr.io`.

This change reverts #591 and instead supports regex in `credHelpers`. This is not supported by Docker itself but highly demanded in https://github.com/docker/cli/issues/2928

I think it's fine to support it for Tart.

Additionally this change bumps the minimum host macOS version to Ventura in order to bring `Regex`. Yes, `Regex` only supported in Swift for macOS 13+ 🤦‍♂️I think it's fine in the light of Sonoma release and Tart 2.0.0.

* Removed Monterey mentions from docs
2023-08-28 11:37:49 -04:00
Fedor Korotkov f45551cbf0 Support Docker's credsStore (#591)
This way for #581 we don't need to specify a fully quialified URL and can simply use the following `~/.docker/config.json`:

```json
{
	"credsStore": "ecr-login"
}
```

Related to https://github.com/docker/cli/issues/2928
2023-08-21 19:44:35 +00:00
Nikolay Edigaryev f68297097e Add a simple integration test for "tart run" (#587)
* Add a simple integration test for "tart run"

* Integration tests: only "tart list" local VM images
2023-08-16 04:04:52 -04:00
Fedor Korotkov 637a2387e7 No bridged network interfaces by default (#586)
Fixes #585
2023-08-15 19:47:25 +04:00
Fedor Korotkov 050d6a6ff1 Clarify host cpu core usage (#584)
* Clarify usage of cores of the host CPU

* Updated phrasing
2023-08-15 11:25:30 +00:00
Nikolay Edigaryev 5eddd1ce41 Introduce "tart logout" command (#583)
* Introduce "tart logout"

* tart login: introduce --no-validate
2023-08-15 15:16:33 +04:00
Fedor Korotkov 35f5b30bc4 Multiple bridged interfaces (#578)
* Support multiple Bridged Network interfaces

Fixes #572

* Allow duplicated bridged interfaces
2023-08-14 19:17:38 +04:00
Fedor Korotkov 8b27fea745 Document running scripts via ssh (#579) 2023-08-14 06:45:44 -04:00
fedor e00f62c95a Bumped dotlottie file 2023-08-10 13:52:27 -04:00
TommyandFedor Korotkov d90893e9a0 Add CONTRIBUTING.md? (#575)
* Create contribute.md

* Update CONTRIBUTING.md

Co-Authored-By: Fedor Korotkov <fedor.korotkov@gmail.com>

* Update CONTRIBUTING.md

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

* Update CONTRIBUTING.md

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

* missing '''

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-08-03 13:10:34 -04:00
TommyandFedor Korotkov feb733a7c0 GC avoidance and tmpDeterminstic (#570)
* GC avoidance and tmpDeterminstic

* change tmpDeterministic to use hashing

- temporaryDeterministic() now takes in a key and hashes it
- creates directory with the hash

* Update Sources/tart/VMStorageOCI.swift

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

---------

Co-authored-by: Fedor Korotkov <fedor.korotkov@gmail.com>
2023-07-31 18:21:42 +00:00
Fedor Korotkov 545b6fcd94 Provide license usage examples (#568) 2023-07-27 12:06:40 -04:00
Nikolay Edigaryev c750d63ac9 Clarify licensing (#566)
* Clarify licensing

* Refactor "License Tiers" section
2023-07-26 14:50:44 +00:00
Fedor Korotkov 704811e671 Introduce TART_NO_AUTO_PRUNE (#565)
* Introduce `TART_NO_AUTO_PRUNE`

Similar to `HOMEBREW_NO_AUTO_UPDATE`

* Self review
2023-07-25 16:01:53 +00:00
Fedor Korotkov d4fcecd47c [skip ci] Downgrade Lottie Player (#563)
Seems in 2.0.0 release they broke looping. Right now animation on https://tart.run/ is always looping even without `loop` property. I was able to disable it, so I just downgraded to the last known working version.
2023-07-20 19:46:58 +04:00
Nikolay Edigaryev 33ca96e1a0 Retrieve VM's IP for use in VNC after the VM is started #2 (#562) 2023-07-17 12:51:43 +04:00
Fedor Korotkov 4ce06279ff [skip ci] Clarify default runner group assigment (#559) 2023-07-14 07:50:31 -04:00
Fedor Korotkov fa97adfc9e Highlight Cirrus Runners in README.md (#558)
* Mention Cirrus Runners in the README.md

* Increase font size

* typo

* Fixed branch
2023-07-13 18:09:19 +00:00
Nikolay Edigaryev 6c377029d6 tart prune: allow pruning local VMs with --entries=vms (#557) 2023-07-13 22:05:28 +04:00
Fedor Korotkov 93a1b70ecb Don't delete an initiator of pruning (#556)
* Don't delete an initiator of pruning

Sometimes people have an image that is greater than half of the disk itself. In that case such image will be pulled and prunned right away.

This change makes sure that an image that is being cloned from is not pruned right away.

* Resolve symbolic links
2023-07-13 13:59:23 +04:00
40 changed files with 412 additions and 150 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
use_compute_credits: true
env:
XCODE_TAG: 15-beta-2
XCODE_TAG: 15-beta-5
task:
name: Test on Ventura
+1 -1
View File
@@ -45,7 +45,7 @@ brews:
libexec.install Dir["*"]
bin.write_exec_script "#{libexec}/tart.app/Contents/MacOS/tart"
custom_block: |
depends_on :macos => :monterey
depends_on :macos => :ventura
on_macos do
unless Hardware::CPU.arm?
+40
View File
@@ -0,0 +1,40 @@
# Contributing to Tart
Table of Contents
-----------------
- [How to Build](#how-to-build)
- [How to Create an Issue/Enhancement](#how-to-create-an-issueenhancement)
- [Style Guidelines](#style-guidelines)
- [Pull Requests](#Pull-Requests)
## How to Build
1. Fork the repository to your own GitHub account
2. Clone the forked repository to your local machine
3. If using Xcode, use from Xcode 15 or newer
4. Run ./scripts/run-signed.sh from the root of your repository
```bash
./scripts/run-signed.sh list
```
## How to Create an Issue/Enhancement
1. Go to the [Issue page](https://github.com/cirruslabs/tart/issues) of the repository
2. Click on the "New Issue" button
3. Provide a descriptive title and detailed description of the issue or enhancement you're suggesting
4. Submit the issue
## Style Guidelines
1. Code should follow camel case
2. Code should follow [SwiftFormat](https://github.com/nicklockwood/SwiftFormat#swift-package-manager-plugin) guidelines. You can auto-format the code by running the following command:
```bash
swift package plugin --allow-writing-to-package-directory swiftformat --cache ignore .
```
## Pull Requests
1. Provide a detailed description of the changes you made in the pull request
2. Wait for pull request to be reviewed
3. Make adjustments if necessary
+1 -1
View File
@@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "Tart",
platforms: [
.macOS(.v12)
.macOS(.v13)
],
products: [
.executable(name: "tart", targets: ["tart"])
+12 -3
View File
@@ -6,9 +6,18 @@ Built by CI engineers for your automation needs. Here are some highlights of Tar
* Tart uses Apple's own `Virtualization.Framework` for [near-native performance](https://browser.geekbench.com/v5/cpu/compare/20382844?baseline=20382722).
* Push/Pull virtual machines from any OCI-compatible container registry.
* Use Tart Packer Plugin to automate VM creation.
* Built-in CI integration.
* Easily integrates with any CI system.
*Tart* is already adopted by several automation services:
Tart powers [Cirrus Runners](https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral)
service — a drop-in replacement for the standard GitHub-hosted runners, offering 2-3 times better performance for a fraction of the price.
<p align="center">
<a href="https://tart.run/integrations/github-actions/?utm_source=github&utm_medium=referral" target=_blank>
<img src="https://github.com/cirruslabs/tart/raw/main/Resources/CirrusRunnersForGHA.png" height="65"/>
</a>
</p>
Tart is also adopted by several other automation services:
<p align="center">
<a href="https://cirrus-ci.org/guide/macOS/" target=_blank>
@@ -52,7 +61,7 @@ Many more companies are using Tart in their internal setups. Here are a few of t
## Usage
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+15 -2
View File
@@ -3,7 +3,20 @@ import Foundation
import SystemConfiguration
struct Clone: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Clone a VM")
static var configuration = CommandConfiguration(
abstract: "Clone a VM",
discussion: """
Creates a local virtual machine by cloning either a remote or another local virtual machine.
Due to copy-on-write magic in Apple File System a cloned VM won't actually claim all the space right away.
Only changes to a cloned disk will be written and claim new space. By default, Tart checks available capacity
in Tart's home directory and checks if there is enough space for the worst possible scenario: when the whole disk
will be modified.
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable. This might be helpful
for use cases when the original image is very big and a workload is known to only modify a fraction of the cloned disk.
"""
)
@Argument(help: "source VM name")
var sourceName: String
@@ -53,7 +66,7 @@ struct Clone: AsyncParsableCommand {
// APFS is doing copy-on-write so the above cloning operation (just copying files on disk)
// is not actually claiming new space until the VM is started and it writes something to disk.
// So once we clone the VM let's try to claim a little bit of space for the VM to run.
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()))
try Prune.reclaimIfNeeded(UInt64(sourceVM.sizeBytes()), sourceVM)
}, onCancel: {
try? FileManager.default.removeItem(at: tmpVMDir.baseURL)
})
+1 -5
View File
@@ -47,11 +47,7 @@ struct Create: AsyncParsableCommand {
}
if linux {
if #available(macOS 13, *) {
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
}
try VMStorageLocal().move(name, from: tmpVMDir)
+11 -6
View File
@@ -17,6 +17,9 @@ struct Login: AsyncParsableCommand {
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
var insecure: Bool = false
@Flag(help: "skip validation of the registry's credentials before logging-in")
var noValidate: Bool = false
func validate() throws {
let usernameProvided = username != nil
let passwordProvided = passwordStdin
@@ -45,12 +48,14 @@ struct Login: AsyncParsableCommand {
host: (user, password)
])
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
if !noValidate {
do {
let registry = try Registry(host: host, namespace: "", insecure: insecure,
credentialsProviders: [credentialsProvider])
try await registry.ping()
} catch {
throw RuntimeError.InvalidCredentials("invalid credentials: \(error)")
}
}
try KeychainCredentialsProvider().store(host: host, user: user, password: password)
+14
View File
@@ -0,0 +1,14 @@
import ArgumentParser
import Dispatch
import SwiftUI
struct Logout: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Logout from a registry")
@Argument(help: "host")
var host: String
func run() async throws {
try KeychainCredentialsProvider().remove(host: host)
}
}
+56 -20
View File
@@ -5,23 +5,40 @@ import SwiftUI
import SwiftDate
struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
@Option(help: ArgumentHelp("Remove cache entries last accessed more than n days ago",
@Option(help: ArgumentHelp("Entries to remove: \"caches\" targets OCI and IPSW caches and \"vms\" targets local VMs."))
var entries: String = "caches"
@Option(help: ArgumentHelp("Remove entries that were last accessed more than n days ago",
discussion: "For example, --older-than=7 will remove entries that weren't accessed by Tart in the last 7 days.",
valueName: "n"))
var olderThan: UInt?
@Option(help: ArgumentHelp("Remove least recently used cache entries that do not fit the specified cache size budget n, expressed in gigabytes",
discussion: "For example, --cache-budget=50 will effectively shrink all caches to a total size of 50 gigabytes.",
valueName: "n"))
@Option(help: .hidden)
var cacheBudget: UInt?
@Option(help: ArgumentHelp("Remove the least recently used entries that do not fit the specified space size budget n, expressed in gigabytes",
discussion: "For example, --space-budget=50 will effectively shrink all entries to a total size of 50 gigabytes.",
valueName: "n"))
var spaceBudget: UInt?
@Flag(help: .hidden)
var gc: Bool = false
func validate() throws {
if olderThan == nil && cacheBudget == nil && !gc {
mutating func validate() throws {
// --cache-budget deprecation logic
if let cacheBudget = cacheBudget {
fputs("--cache-budget is deprecated, please use --space-budget\n", stderr)
if spaceBudget != nil {
throw ValidationError("--cache-budget is deprecated, please use --space-budget")
}
spaceBudget = cacheBudget
}
if olderThan == nil && spaceBudget == nil && !gc {
throw ValidationError("at least one pruning criteria must be specified")
}
}
@@ -31,43 +48,53 @@ struct Prune: AsyncParsableCommand {
try VMStorageOCI().gc()
}
// Build a list of prunable storages that we're going to prune based on user's request
let prunableStorages: [PrunableStorage]
switch entries {
case "caches":
prunableStorages = [VMStorageOCI(), try IPSWCache()]
case "vms":
prunableStorages = [VMStorageLocal()]
default:
throw ValidationError("unsupported --entries value, please specify either \"caches\" or \"vms\"")
}
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date() - olderThanInterval
try Prune.pruneOlderThan(olderThanDate: olderThanDate)
try Prune.pruneOlderThan(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
}
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
if let cacheBudget = cacheBudget {
try Prune.pruneCacheBudget(cacheBudgetBytes: UInt64(cacheBudget) * 1024 * 1024 * 1024)
if let spaceBudget = spaceBudget {
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
}
}
static func pruneOlderThan(olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
static func pruneOlderThan(prunableStorages: [PrunableStorage], olderThanDate: Date) throws {
let prunables: [Prunable] = try prunableStorages.flatMap { try $0.prunables() }
try prunables.filter { try $0.accessDate() <= olderThanDate }.forEach { try $0.delete() }
}
static func pruneCacheBudget(cacheBudgetBytes: UInt64) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
static func pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() > $1.accessDate() }
var cacheBudgetBytes = cacheBudgetBytes
var spaceBudgetBytes = spaceBudgetBytes
var prunablesToDelete: [Prunable] = []
for prunable in prunables {
let prunableSizeBytes = UInt64(try prunable.sizeBytes())
if prunableSizeBytes <= cacheBudgetBytes {
if prunableSizeBytes <= spaceBudgetBytes {
// Don't mark for deletion as
// there's a budget available
cacheBudgetBytes -= prunableSizeBytes
spaceBudgetBytes -= prunableSizeBytes
} else {
// Mark for deletion
prunablesToDelete.append(prunable)
@@ -77,7 +104,11 @@ struct Prune: AsyncParsableCommand {
try prunablesToDelete.forEach { try $0.delete() }
}
static func reclaimIfNeeded(_ requiredBytes: UInt64) throws {
static func reclaimIfNeeded(_ requiredBytes: UInt64, _ initiator: Prunable? = nil) throws {
if ProcessInfo.processInfo.environment.keys.contains("TART_NO_AUTO_PRUNE") {
return
}
SentrySDK.configureScope { scope in
scope.setContext(value: ["requiredBytes": requiredBytes], key: "Prune")
}
@@ -114,10 +145,10 @@ struct Prune: AsyncParsableCommand {
return
}
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated)
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
}
private static func reclaimIfPossible(_ reclaimBytes: UInt64) throws {
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
defer { transaction.finish() }
@@ -141,6 +172,11 @@ struct Prune: AsyncParsableCommand {
break
}
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
// do not prune the initiator
continue
}
try SentrySDK.span?.setData(value: prunable.sizeBytes(), key: prunable.url.path)
cacheReclaimedBytes += try prunable.sizeBytes()
+10 -1
View File
@@ -3,7 +3,16 @@ import Dispatch
import SwiftUI
struct Pull: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Pull a VM from a registry")
static var configuration = CommandConfiguration(
abstract: "Pull a VM from a registry",
discussion: """
Pulls a virtual machine from a remote OCI-compatible registry. Supports authorization via Keychain (see "tart login --help"),
Docker credential helpers defined in ~/.docker/config.json or via TART_REGISTRY_USERNAME/TART_REGISTRY_PASSWORD environment variables.
By default, Tart checks available capacity in Tart's home directory and tries to reclaim minimum possible storage for the remote image to fit via "tart prune".
This behaviour can be disabled by setting TART_NO_AUTO_PRUNE environment variable.
"""
)
@Argument(help: "remote VM name")
var remoteName: String
+27 -29
View File
@@ -91,7 +91,7 @@ struct Run: AsyncParsableCommand {
""", discussion: """
Specify "list" as an interface name (--net-bridged=list) to list the available bridged interfaces.
""", valueName: "interface name"))
var netBridged: String?
var netBridged: [String] = []
@Flag(help: ArgumentHelp("Use software networking instead of the default shared (NAT) networking",
discussion: "Learn how to configure Softnet for use with Tart here: https://github.com/cirruslabs/softnet"))
@@ -105,7 +105,7 @@ struct Run: AsyncParsableCommand {
throw ValidationError("--vnc and --vnc-experimental are mutually exclusive")
}
if netBridged != nil && netSoftnet {
if netBridged.count > 0 && netSoftnet {
throw ValidationError("--net-bridged and --net-softnet are mutually exclusive")
}
@@ -220,17 +220,6 @@ struct Run: AsyncParsableCommand {
let task = Task {
do {
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(vncURL)")
} else {
print("Opening \(vncURL)...")
NSWorkspace.shared.open(vncURL)
}
}
var resume = false
if #available(macOS 14, *) {
@@ -243,7 +232,20 @@ struct Run: AsyncParsableCommand {
}
}
try await vm!.run(recovery: recovery, resume: resume)
try await vm!.start(recovery: recovery, resume: resume)
if let vncImpl = vncImpl {
let vncURL = try await vncImpl.waitForURL()
if noGraphics || ProcessInfo.processInfo.environment["CI"] != nil {
print("VNC server is running at \(vncURL)")
} else {
print("Opening \(vncURL)...")
NSWorkspace.shared.open(vncURL)
}
}
try await vm!.run()
if let vncImpl = vncImpl {
try vncImpl.stop()
@@ -329,23 +331,19 @@ struct Run: AsyncParsableCommand {
return try Softnet(vmMACAddress: config.macAddress.string)
}
if let netBridged = netBridged {
let matchingInterfaces = VZBridgedNetworkInterface.networkInterfaces.filter { interface in
interface.identifier == netBridged || interface.localizedDisplayName == netBridged
if netBridged.count > 0 {
func findBridgedInterface(_ name: String) throws -> VZBridgedNetworkInterface {
let interface = VZBridgedNetworkInterface.networkInterfaces.first { interface in
interface.identifier == name || interface.localizedDisplayName == name
}
if (interface == nil) {
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(bridgeInterfaces())")
}
return interface!
}
if matchingInterfaces.isEmpty {
let available = bridgeInterfaces().joined(separator: ", ")
throw ValidationError("no bridge interfaces matched \"\(netBridged)\", "
+ "available interfaces: \(available)")
}
if matchingInterfaces.count > 1 {
throw ValidationError("more than one bridge interface matched \"\(netBridged)\", "
+ "consider refining the search criteria")
}
return NetworkBridged(interface: matchingInterfaces.first!)
return NetworkBridged(interfaces: try netBridged.map { try findBridgedInterface($0) })
}
return nil
@@ -11,7 +11,7 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
if let credentialsFromAuth = config.auths?[host]?.decodeCredentials() {
return credentialsFromAuth
}
if let helperProgram = config.credHelpers?[host] {
if let helperProgram = try config.findCredHelper(host: host) {
return try executeHelper(binaryName: "docker-credential-\(helperProgram)", host: host)
}
@@ -59,6 +59,26 @@ class DockerConfigCredentialsProvider: CredentialsProvider {
struct DockerConfig: Codable {
var auths: Dictionary<String, DockerAuthConfig>? = Dictionary()
var credHelpers: Dictionary<String, String>? = Dictionary()
func findCredHelper(host: String) throws -> String? {
// Tart supports wildcards in credHelpers
// Similar to what is requested from Docker: https://github.com/docker/cli/issues/2928
guard let credHelpers else {
return nil
}
for (hostPattern, helperProgram) in credHelpers {
if (hostPattern == host) {
return helperProgram
}
let compiledPattern = try? Regex(hostPattern)
if (try compiledPattern?.wholeMatch(in: host) != nil) {
return helperProgram
}
}
return nil
}
}
struct DockerAuthConfig: Codable {
@@ -61,6 +61,24 @@ class KeychainCredentialsProvider: CredentialsProvider {
throw CredentialsProviderError.Failed(message: "Keychain failed to find item: \(status.explanation())")
}
}
func remove(host: String) throws {
let query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: host,
kSecAttrLabel as String: "Tart Credentials",
]
let status = SecItemDelete(query as CFDictionary)
switch status {
case errSecSuccess:
return
case errSecItemNotFound:
return
default:
throw CredentialsProviderError.Failed(message: "Failed to remove Keychain item(s): \(status.explanation())")
}
}
}
extension OSStatus {
+1 -1
View File
@@ -1,7 +1,7 @@
import Virtualization
protocol Network {
func attachment() -> VZNetworkDeviceAttachment
func attachments() -> [VZNetworkDeviceAttachment]
func run(_ sema: DispatchSemaphore) throws
func stop() async throws
}
+5 -5
View File
@@ -2,14 +2,14 @@ import Foundation
import Virtualization
class NetworkBridged: Network {
let interface: VZBridgedNetworkInterface
let interfaces: [VZBridgedNetworkInterface]
init(interface: VZBridgedNetworkInterface) {
self.interface = interface
init(interfaces: [VZBridgedNetworkInterface]) {
self.interfaces = interfaces
}
func attachment() -> VZNetworkDeviceAttachment {
VZBridgedNetworkDeviceAttachment(interface: interface)
func attachments() -> [VZNetworkDeviceAttachment] {
interfaces.map { VZBridgedNetworkDeviceAttachment(interface: $0) }
}
func run(_ sema: DispatchSemaphore) throws {
+2 -2
View File
@@ -2,8 +2,8 @@ import Foundation
import Virtualization
class NetworkShared: Network {
func attachment() -> VZNetworkDeviceAttachment {
VZNATNetworkDeviceAttachment()
func attachments() -> [VZNetworkDeviceAttachment] {
[VZNATNetworkDeviceAttachment()]
}
func run(_ sema: DispatchSemaphore) throws {
+2 -2
View File
@@ -92,9 +92,9 @@ class Softnet: Network {
}
}
func attachment() -> VZNetworkDeviceAttachment {
func attachments() -> [VZNetworkDeviceAttachment] {
let fh = FileHandle.init(fileDescriptor: vmFD)
return VZFileHandleNetworkDeviceAttachment(fileHandle: fh)
return [VZFileHandleNetworkDeviceAttachment(fileHandle: fh)]
}
static func configureSUIDBitIfNeeded() throws {
+3 -11
View File
@@ -116,19 +116,11 @@ struct Darwin: PlatformSuspendable {
}
func pointingDevices() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
// Trackpad is only supported by guests starting with macOS Ventura
return [VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
} else {
return [VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
// Trackpad is only supported by guests starting with macOS Ventura
[VZMacTrackpadConfiguration(), VZUSBScreenCoordinatePointingDeviceConfiguration()]
}
func pointingDevicesSuspendable() -> [VZPointingDeviceConfiguration] {
if #available(macOS 13, *) {
return [VZMacTrackpadConfiguration()]
} else {
return []
}
[VZMacTrackpadConfiguration()]
}
}
+7 -4
View File
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
Get.self,
List.self,
Login.self,
Logout.self,
IP.self,
Pull.self,
Push.self,
@@ -81,10 +82,12 @@ struct Root: AsyncParsableCommand {
var command = try parseAsRoot()
// Run garbage-collection before each command (shouldn't take too long)
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
if type(of: command) != type(of: Pull()) && type(of: command) != type(of: Clone()){
do {
try Config().gc()
} catch {
fputs("Failed to perform garbage collection!\n\(error)\n", stderr)
}
}
if var asyncCommand = command as? AsyncParsableCommand {
+20 -22
View File
@@ -225,7 +225,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
return try VM(vmDir: vmDir)
}
func run(recovery: Bool, resume shouldResume: Bool) async throws {
func start(recovery: Bool, resume shouldResume: Bool) async throws {
try network.run(sema)
if shouldResume {
@@ -233,7 +233,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
} else {
try await start(recovery)
}
}
func run() async throws {
await withTaskCancellationHandler(operation: {
// Wait for the VM to finish running
// or for the exit condition
@@ -251,15 +253,9 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
@MainActor
private func start(_ recovery: Bool) async throws {
if #available(macOS 13, *) {
// new API introduced in Ventura
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
try await virtualMachine.start(options: startOptions)
} else {
// use method that also available on Monterey
try await virtualMachine.start(recovery)
}
let startOptions = VZMacOSVirtualMachineStartOptions()
startOptions.startUpFromMacOSRecovery = recovery
try await virtualMachine.start(options: startOptions)
}
@MainActor
@@ -318,15 +314,19 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
// Networking
let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = network.attachment()
vio.macAddress = vmConfig.macAddress
configuration.networkDevices = [vio]
configuration.networkDevices = network.attachments().map {
let vio = VZVirtioNetworkDeviceConfiguration()
vio.attachment = $0
vio.macAddress = vmConfig.macAddress
return vio
}
// Storage
var attachments = [try VZDiskImageStorageDeviceAttachment(url: diskURL, readOnly: false)]
attachments.append(contentsOf: additionalDiskAttachments)
configuration.storageDevices = attachments.map { VZVirtioBlockDeviceConfiguration(attachment: $0) }
configuration.storageDevices = attachments.map {
VZVirtioBlockDeviceConfiguration(attachment: $0)
}
// Entropy
if !suspendable {
@@ -343,15 +343,13 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
//
// A dummy console device useful for implementing
// host feature checks in the guest agent software.
if #available(macOS 13, *) {
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consolePort = VZVirtioConsolePortConfiguration()
consolePort.name = "tart-version-\(CI.version)"
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
let consoleDevice = VZVirtioConsoleDeviceConfiguration()
consoleDevice.ports[0] = consolePort
configuration.consoleDevices.append(consoleDevice)
}
configuration.consoleDevices.append(consoleDevice)
try configuration.validate()
+1 -5
View File
@@ -97,11 +97,7 @@ struct VMConfig: Codable {
case .darwin:
platform = try Darwin(from: decoder)
case .linux:
if #available(macOS 13, *) {
platform = try Linux(from: decoder)
} else {
throw UnsupportedOSError("Linux VMs", "are")
}
platform = try Linux(from: decoder)
}
cpuCountMin = try container.decode(Int.self, forKey: .cpuCountMin)
cpuCount = try container.decode(Int.self, forKey: .cpuCount)
+12
View File
@@ -1,5 +1,6 @@
import Foundation
import Virtualization
import CryptoKit
struct VMDirectory: Prunable {
var baseURL: URL
@@ -59,6 +60,17 @@ struct VMDirectory: Prunable {
return VMDirectory(baseURL: tmpDir)
}
//Create tmp directory with hashing
static func temporaryDeterministic(key: String) throws -> VMDirectory {
let keyData = Data(key.utf8)
let hash = Insecure.MD5.hash(data: keyData)
// Convert hash to string
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()
let tmpDir = try Config().tartTmpDir.appendingPathComponent(hashString)
try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true)
return VMDirectory(baseURL: tmpDir)
}
var initialized: Bool {
FileManager.default.fileExists(atPath: configURL.path) &&
FileManager.default.fileExists(atPath: diskURL.path) &&
+7 -1
View File
@@ -1,6 +1,6 @@
import Foundation
class VMStorageLocal {
class VMStorageLocal: PrunableStorage {
let baseURL: URL = try! Config().tartHomeDir.appendingPathComponent("vms", isDirectory: true)
private func vmURL(_ name: String) -> URL {
@@ -16,6 +16,8 @@ class VMStorageLocal {
try vmDir.validate(userFriendlyName: name)
try vmDir.baseURL.updateAccessDate()
return vmDir
}
@@ -63,6 +65,10 @@ class VMStorageLocal {
}
}
func prunables() throws -> [Prunable] {
try list().map { (_, vmDir) in vmDir }
}
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
try list().contains { try $1.macAddress() == macAddress }
}
+1 -1
View File
@@ -170,7 +170,7 @@ class VMStorageOCI: PrunableStorage {
if !exists(digestName) {
let transaction = SentrySDK.startTransaction(name: name.description, operation: "pull", bindToScope: true)
let tmpVMDir = try VMDirectory.temporary()
let tmpVMDir = try VMDirectory.temporaryDeterministic(key: name.description)
// Lock the temporary VM directory to prevent it's garbage collection
let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL)
+16
View File
@@ -0,0 +1,16 @@
import XCTest
@testable import tart
final class DockerConfigTests: XCTestCase {
func testHelpers() throws {
let config = DockerConfig(credHelpers: [
"(.*).dkr.ecr.(.*).amazonaws.com": "ecr-login",
"gcr.io": "gcloud"
])
XCTAssertEqual(try config.findCredHelper(host: "gcr.io"), "gcloud")
XCTAssertEqual(try config.findCredHelper(host: "123.dkr.ecr.eu-west-1.amazonaws.com"), "ecr-login")
XCTAssertEqual(try config.findCredHelper(host: "456.dkr.ecr.us-east-1.amazonaws.com"), "ecr-login")
XCTAssertNil(try config.findCredHelper(host: "ghcr.io"))
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ task:
name: hello
macos_instance:
# can be a remote or a local virtual machine
image: ghcr.io/cirruslabs/macos-monterey-base:latest
image: ghcr.io/cirruslabs/macos-ventura-base:latest
hello_script:
- echo "Hello from within a Tart VM!"
- echo "Here is my CPU info:"
@@ -45,7 +45,7 @@ exposes it via [`artifacts` instruction](https://cirrus-ci.org/guide/writing-tas
task:
name: Build
macos_instance:
image: ghcr.io/cirruslabs/macos-monterey-xcode:latest
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
build_script: swift build --product tart
binary_artifacts:
path: .build/debug/tart
+1
View File
@@ -28,5 +28,6 @@ jobs:
```
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`.
Note that Cirrus Runners will get added to the default runner group. By default, only private repositories can access runners in a default runner group, but you can override this in your organization's settings.
![](/assets/images/TartGHARunners.png)
+3 -3
View File
@@ -11,8 +11,8 @@ Tart can create VMs from `*.ipsw` files. You can download a specific `*.ipsw` fi
use `latest` instead of a path to `*.ipsw` to download the latest available version:
```bash
tart create --from-ipsw=latest monterey-vanilla
tart run monterey-vanilla
tart create --from-ipsw=latest ventura-vanilla
tart run ventura-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:
@@ -54,7 +54,7 @@ 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:
Here is an example of a template to build a local image based of a remote image:
```hcl
packer {
+42 -8
View File
@@ -8,19 +8,53 @@ are licensed under [Fair Source License](https://fair.io/). Usage on personal co
but organizations that exceed a certain number of server installations (100 CPU cores for Tart and/or 4 hosts for Orchard)
will be required to obtain a paid license.
??? note "Performance and Efficiency Cores"
The virtual CPU cores in Tart VMs do not differentiate between the high-performance and high-efficient cores
of the host CPU. Instead, Tart VMs automatically alternate between these types of cores depending on the workload
being executed within the virtual machines. As a result, both performance and energy-efficient cores of the host CPU
are treated equally in terms of licensing.
??? note "Host CPU Core usage"
The virtual CPU cores of Tart VMs are not tied to specific physical cores of the host CPU. Instead, for optimal performance
Tart VMs will automatically try to balance compute between all available cores of the host CPU. As a result,
all performance and energy-efficient cores of the host CPU are always counted towards the license usage.
# License Tiers
When an organization surpasses the 100 CPU cores limit, it is required to obtain a Gold Tier License, which costs \$1000 per month.
Upon reaching a limit of 500 CPU cores, a Platinum Tier License (\$5000 per month) will be required, and for organizations
that exceed 5000 CPU cores, a custom Diamond Tier License (\$1 per core per month) will be necessary.
## Free Tier
By default, when no [license is purchased](#get-the-license), it is assumed that an organization is using a Free Tier license.
You can find the Free Tier license text in [Tart](https://github.com/cirruslabs/tart/blob/main/LICENSE) and [Orchard](https://github.com/cirruslabs/orchard/blob/main/LICENSE) repositories.
Free Tier license has a 100 CPU core limit for Tart and 4 Orchard Workers limit for Orchard.
??? info "Usage Scenarios Examples"
Here are a few examples that fit into the free tier:
- Using Tart on 12 Mac Minis with 8 CPUs each running up to 24 VMs in parallel.
- Creating an Orchard cluster of 4 Mac Studio workers with 24 CPUs each.
Here are a few examples that do not fit into the free tier:
- Using Tart on 13 Mac Minis with 8 CPUs each.
- Creating an Orchard cluster of 5 Mac Minis workers with 8 CPUs each.
## Gold Tier
If an organization wishes to exceed the limits of the Free Tier license, a purchase of the [Gold Tier License](#get-the-license) is required, which costs \$1000 per month.
Gold Tier license has a 500 CPU core limit for Tart and 20 Orchard Workers limit for Orchard.
## Platinum Tier
If an organization wishes to exceed the limits of the Gold Tier license, a purchase of the [Platinum Tier License](#get-the-license) is required, which costs \$5000 per month.
Platinum Tier license has a 5,000 CPU core limit for Tart and 200 Orchard Workers limit for Orchard.
## Diamond Tier
For organizations that wish to exceed the limits of the Platinum Tier license, a purchase of a [custom Diamond Tier License](#get-the-license) is required, which costs \$1 per CPU core per month and gives the ability to run unlimited Orchard Workers.
# Get the license
If your organization is interested in purchasing one of the license tiers, please email [licensing@cirruslabs.org](mailto:licensing@cirruslabs.org).
You can see a template of a license subscription agreement [here](assets/TartLicenseSubscription.pdf).
# General Support
+12 -2
View File
@@ -3,7 +3,7 @@ hide:
- navigation
---
Try running a Tart VM on your Apple Silicon device running macOS 12.0 (Monterey) or later (will download a 25 GB image):
Try running a Tart VM on your Apple Silicon device running macOS 13.0 (Ventura) or later (will download a 25 GB image):
```bash
brew install cirruslabs/cli/tart
@@ -33,9 +33,19 @@ tart run ventura-base
If the guest VM is running and configured to accept incoming SSH connections you can conveniently connect to it like so:
```bash
ssh admin@$(tart ip macos-ventura-base)
ssh admin@$(tart ip ventura-base)
```
!!! tip "Running scripts inside Tart virtual machines"
We recommend using [Cirrus CLI](integrations/cirrus-cli.md) to run scripts and/or retrieve artifacts
from within Tart virtual machines. Alternatively, you can use plain ssh connection and `tart ip` command:
```bash
brew install sshpass
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) "uname -a"
sshpass -p admin ssh -o "StrictHostKeyChecking no" admin@$(tart ip ventura-base) < script.sh
```
## Mounting directories
To mount a directory, run the VM with the `--dir` argument:
+1 -1
View File
@@ -81,7 +81,7 @@
}
</style>
<script src="https://unpkg.com/@dotlottie/player-component@latest/dist/dotlottie-player.js"></script>
<script src="https://unpkg.com/@dotlottie/player-component@1.4.2/dist/dotlottie-player.js"></script>
<!-- landing page for landing page -->
<!-- Hero -->
+1
View File
@@ -3,3 +3,4 @@ testcontainers
requests
bitmath
pytest-dependency
paramiko
+9 -4
View File
@@ -7,10 +7,9 @@ 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"))
# Link to the users cache to make things faster
src = os.path.join(os.path.expanduser("~"), ".tart", "cache")
dst = os.path.join(self.tmp_dir.name, "cache")
os.symlink(src, dst)
def __enter__(self):
@@ -31,3 +30,9 @@ class Tart:
completed_process.check_returncode()
return completed_process.stdout.decode("utf-8"), completed_process.stderr.decode("utf-8")
def run_async(self, args) -> subprocess.Popen:
env = os.environ.copy()
env.update({"TART_HOME": self.tmp_dir.name})
return subprocess.Popen(["tart"] + args, env=env)
+1 -1
View File
@@ -6,5 +6,5 @@ def test_clone(tart):
tart.run(["clone", "debian", "ubuntu"])
# Ensure that we have now 2 VMs
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\nubuntu\n"
+2 -2
View File
@@ -3,7 +3,7 @@ def test_create_macos(tart):
tart.run(["create", "--from-ipsw", "latest", "macos-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "macos-vm\n"
@@ -12,5 +12,5 @@ def test_create_linux(tart):
tart.run(["create", "--linux", "linux-vm"])
# Ensure that the VM was created
stdout, _ = tart.run(["list", "--quiet"])
stdout, _ = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "linux-vm\n"
+2 -2
View File
@@ -3,12 +3,12 @@ def test_delete(tart):
tart.run(["create", "--linux", "debian"])
# Ensure that the VM exists
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "debian\n"
# Delete the VM
tart.run(["delete", "debian"])
# Ensure that the VM was removed
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == ""
+1 -1
View File
@@ -6,5 +6,5 @@ def test_rename(tart):
tart.run(["rename", "debian", "ubuntu"])
# Ensure that the VM is now named "ubuntu"
stdout, _, = tart.run(["list", "--quiet"])
stdout, _, = tart.run(["list", "--source", "local", "--quiet"])
assert stdout == "ubuntu\n"
+30
View File
@@ -0,0 +1,30 @@
import uuid
from paramiko.client import SSHClient, AutoAddPolicy
def test_run(tart):
vm_name = f"integration-test-run-{uuid.uuid4()}"
# Instantiate a VM with admin:admin SSH access
tart.run(["clone", "ghcr.io/cirruslabs/macos-ventura-base:latest", vm_name])
# Run the VM asynchronously
tart_run_process = tart.run_async(["run", vm_name])
# Obtain the VM's IP
stdout, _ = tart.run(["ip", vm_name, "--wait", "120"])
ip = stdout.strip()
# Connect to the VM over SSH and shutdown it
client = SSHClient()
client.set_missing_host_key_policy(AutoAddPolicy)
client.connect(ip, username="admin", password="admin")
client.exec_command("sudo shutdown -h now")
# Wait for the "tart run" to finish successfully
tart_run_process.wait()
assert tart_run_process.returncode == 0
# Delete the VM
_, _ = tart.run(["delete", vm_name])