diff --git a/README.md b/README.md
index b860be31..03a3da01 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
+
+
+
+
+
+
+Tart is also adopted by several other automation services:
diff --git a/Resources/CirrusRunnersForGHA.png b/Resources/CirrusRunnersForGHA.png
new file mode 100644
index 00000000..fbc57991
Binary files /dev/null and b/Resources/CirrusRunnersForGHA.png differ
diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift
index 799c57bc..4013921d 100644
--- a/Sources/tart/Commands/Clone.swift
+++ b/Sources/tart/Commands/Clone.swift
@@ -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
diff --git a/Sources/tart/Commands/Prune.swift b/Sources/tart/Commands/Prune.swift
index 0521c29f..abb555ec 100644
--- a/Sources/tart/Commands/Prune.swift
+++ b/Sources/tart/Commands/Prune.swift
@@ -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)
@@ -78,6 +105,10 @@ struct Prune: AsyncParsableCommand {
}
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")
}
diff --git a/Sources/tart/Commands/Pull.swift b/Sources/tart/Commands/Pull.swift
index 6ffd6a90..c728689b 100644
--- a/Sources/tart/Commands/Pull.swift
+++ b/Sources/tart/Commands/Pull.swift
@@ -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
diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift
index dd3a5476..07d68be0 100644
--- a/Sources/tart/Commands/Run.swift
+++ b/Sources/tart/Commands/Run.swift
@@ -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()
diff --git a/Sources/tart/VM.swift b/Sources/tart/VM.swift
index d93d8759..a97ee2c1 100644
--- a/Sources/tart/VM.swift
+++ b/Sources/tart/VM.swift
@@ -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
diff --git a/Sources/tart/VMStorageLocal.swift b/Sources/tart/VMStorageLocal.swift
index 531decf1..b1fc5769 100644
--- a/Sources/tart/VMStorageLocal.swift
+++ b/Sources/tart/VMStorageLocal.swift
@@ -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 }
}
diff --git a/docs/integrations/github-actions.md b/docs/integrations/github-actions.md
index d63ceef7..3b8e9082 100644
--- a/docs/integrations/github-actions.md
+++ b/docs/integrations/github-actions.md
@@ -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//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.

diff --git a/docs/theme/overrides/home.html b/docs/theme/overrides/home.html
index 3a601def..7ca69661 100644
--- a/docs/theme/overrides/home.html
+++ b/docs/theme/overrides/home.html
@@ -81,7 +81,7 @@
}
-
+