mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-22 03:24:40 +02:00
Right now we show only actual size of files on disk which excludes empty blocks of the recently introduced sparced format in #671. This makes impossible to get info about disk size that we just set via `tart set`. Here is an example of `tart list` output before the change: ``` Source Name Size State local sonoma-base 22 stopped local sonoma-vanilla 18 stopped local sonoma-xcode 67 stopped local ubuntu 1 stopped oci ghcr.io/cirruslabs/macos-sonoma-base:latest 22 stopped oci ghcr.io/cirruslabs/macos-sonoma-base@sha256:16c1593bbaf787b20b3c0bc094c5b6baf71c937d22c2e4596da85ac55c92e6cc 22 stopped oci ghcr.io/cirruslabs/macos-sonoma-vanilla:14.3 17 stopped oci ghcr.io/cirruslabs/macos-sonoma-vanilla@sha256:23c4e853d48d00a4333346d66a32b2b5aad900cc0dc10e7ecb9dbe67b6f587f4 17 stopped oci ghcr.io/cirruslabs/macos-sonoma-xcode:latest 67 stopped oci ghcr.io/cirruslabs/macos-sonoma-xcode@sha256:d0cb8d01424a68b89e0f16f5371bf2152b2c115bd886341a6ba8da42121d1f41 67 stopped oci ghcr.io/cirruslabs/ubuntu:22.04 1 stopped oci ghcr.io/cirruslabs/ubuntu@sha256:037763feb7a15d6077edeb7a097738c34313637d16036764b4c196d28d8b429c 1 stopped ``` And here is the output after the change: ``` Source Name Disk Size State local sonoma-base 50 22 stopped local sonoma-vanilla 50 18 stopped local sonoma-xcode 90 67 stopped local ubuntu 20 1 stopped oci ghcr.io/cirruslabs/macos-sonoma-base:latest 50 22 stopped oci ghcr.io/cirruslabs/macos-sonoma-base@sha256:16c1593bbaf787b20b3c0bc094c5b6baf71c937d22c2e4596da85ac55c92e6cc 50 22 stopped oci ghcr.io/cirruslabs/macos-sonoma-vanilla:14.3 50 17 stopped oci ghcr.io/cirruslabs/macos-sonoma-vanilla@sha256:23c4e853d48d00a4333346d66a32b2b5aad900cc0dc10e7ecb9dbe67b6f587f4 50 17 stopped oci ghcr.io/cirruslabs/macos-sonoma-xcode:latest 90 67 stopped oci ghcr.io/cirruslabs/macos-sonoma-xcode@sha256:d0cb8d01424a68b89e0f16f5371bf2152b2c115bd886341a6ba8da42121d1f41 90 67 stopped oci ghcr.io/cirruslabs/ubuntu:22.04 20 1 stopped oci ghcr.io/cirruslabs/ubuntu@sha256:037763feb7a15d6077edeb7a097738c34313637d16036764b4c196d28d8b429c 20 1 stopped ``` Additionally, `tart get` will print actual size with a 3 decimal point precision which will help to track growth in disk images for our templates. `tart get` before: ``` CPU Memory Disk Display State 4 8192 67 1024x768 stopped ``` `tart get` after: ``` CPU Memory Disk Size Display State 4 8192 90 67.333 1024x768 stopped ```
190 lines
6.5 KiB
Swift
190 lines
6.5 KiB
Swift
import ArgumentParser
|
|
import Dispatch
|
|
import Sentry
|
|
import SwiftUI
|
|
import SwiftDate
|
|
|
|
struct Prune: AsyncParsableCommand {
|
|
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches or local VMs")
|
|
|
|
@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: .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
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func run() async throws {
|
|
if gc {
|
|
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(prunableStorages: prunableStorages, olderThanDate: olderThanDate)
|
|
}
|
|
|
|
// Clean up cache entries based on imposed cache size limit and entry's last accessed date
|
|
if let spaceBudget = spaceBudget {
|
|
try Prune.pruneSpaceBudget(prunableStorages: prunableStorages, spaceBudgetBytes: UInt64(spaceBudget) * 1024 * 1024 * 1024)
|
|
}
|
|
}
|
|
|
|
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 pruneSpaceBudget(prunableStorages: [PrunableStorage], spaceBudgetBytes: UInt64) throws {
|
|
let prunables: [Prunable] = try prunableStorages
|
|
.flatMap { try $0.prunables() }
|
|
.sorted { try $0.accessDate() > $1.accessDate() }
|
|
|
|
var spaceBudgetBytes = spaceBudgetBytes
|
|
var prunablesToDelete: [Prunable] = []
|
|
|
|
for prunable in prunables {
|
|
let prunableSizeBytes = UInt64(try prunable.allocatedSizeBytes())
|
|
|
|
if prunableSizeBytes <= spaceBudgetBytes {
|
|
// Don't mark for deletion as
|
|
// there's a budget available
|
|
spaceBudgetBytes -= prunableSizeBytes
|
|
} else {
|
|
// Mark for deletion
|
|
prunablesToDelete.append(prunable)
|
|
}
|
|
}
|
|
|
|
try prunablesToDelete.forEach { try $0.delete() }
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
// Figure out how much disk space is available
|
|
let attrs = try Config().tartCacheDir.resourceValues(forKeys: [
|
|
.volumeAvailableCapacityKey,
|
|
.volumeAvailableCapacityForImportantUsageKey
|
|
])
|
|
let volumeAvailableCapacityCalculated = max(
|
|
UInt64(attrs.volumeAvailableCapacity!),
|
|
UInt64(attrs.volumeAvailableCapacityForImportantUsage!)
|
|
)
|
|
|
|
SentrySDK.configureScope { scope in
|
|
scope.setContext(value: [
|
|
"volumeAvailableCapacity": attrs.volumeAvailableCapacity!,
|
|
"volumeAvailableCapacityForImportantUsage": attrs.volumeAvailableCapacityForImportantUsage!,
|
|
"volumeAvailableCapacityCalculated": volumeAvailableCapacityCalculated
|
|
], key: "Prune")
|
|
}
|
|
|
|
if volumeAvailableCapacityCalculated <= 0 {
|
|
SentrySDK.capture(message: "Zero volume capacity reported") { scope in
|
|
scope.setLevel(.warning)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Now that we know how much free space is left,
|
|
// check if we even need to reclaim anything
|
|
if requiredBytes < volumeAvailableCapacityCalculated {
|
|
return
|
|
}
|
|
|
|
try Prune.reclaimIfPossible(requiredBytes - volumeAvailableCapacityCalculated, initiator)
|
|
}
|
|
|
|
private static func reclaimIfPossible(_ reclaimBytes: UInt64, _ initiator: Prunable? = nil) throws {
|
|
let transaction = SentrySDK.startTransaction(name: "Pruning cache", operation: "prune", bindToScope: true)
|
|
defer { transaction.finish() }
|
|
|
|
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
|
|
let prunables: [Prunable] = try prunableStorages
|
|
.flatMap { try $0.prunables() }
|
|
.sorted { try $0.accessDate() < $1.accessDate() }
|
|
|
|
// Does it even make sense to start?
|
|
let cacheUsedBytes = try prunables.map { try $0.allocatedSizeBytes() }.reduce(0, +)
|
|
if cacheUsedBytes < reclaimBytes {
|
|
return
|
|
}
|
|
|
|
var cacheReclaimedBytes: Int = 0
|
|
|
|
var it = prunables.makeIterator()
|
|
|
|
while cacheReclaimedBytes <= reclaimBytes {
|
|
guard let prunable = it.next() else {
|
|
break
|
|
}
|
|
|
|
if prunable.url == initiator?.url.resolvingSymlinksInPath() {
|
|
// do not prune the initiator
|
|
continue
|
|
}
|
|
|
|
try SentrySDK.span?.setData(value: prunable.allocatedSizeBytes(), key: prunable.url.path)
|
|
|
|
cacheReclaimedBytes += try prunable.allocatedSizeBytes()
|
|
|
|
try prunable.delete()
|
|
}
|
|
|
|
SentrySDK.span?.setMeasurement(name: "gc_disk_reclaimed", value: cacheReclaimedBytes as NSNumber, unit: MeasurementUnitInformation.byte);
|
|
}
|
|
}
|