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. ![](/assets/images/TartGHARunners.png) 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 @@ } - +