Skip to content

Commit

Permalink
Introduce "tart prune" command (#164)
Browse files Browse the repository at this point in the history
  • Loading branch information
edigaryev authored Aug 12, 2022
1 parent a80954c commit 4406813
Show file tree
Hide file tree
Showing 13 changed files with 243 additions and 12 deletions.
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@
"version" : "0.9.2"
}
},
{
"identity" : "swiftdate",
"kind" : "remoteSourceControl",
"location" : "https://github.com/malcommac/SwiftDate",
"state" : {
"revision" : "6190d0cefff3013e77ed567e6b074f324e5c5bf5",
"version" : "6.3.1"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
Expand Down
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ let package = Package(
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
.package(url: "https://github.com/swift-server/async-http-client", from: "1.11.4"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.0.0"),
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
],
targets: [
.executableTarget(name: "tart", dependencies: [
Expand All @@ -23,6 +24,7 @@ let package = Package(
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Dynamic", package: "Dynamic"),
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "SwiftDate", package: "SwiftDate"),
]),
.testTarget(name: "TartTests", dependencies: ["tart"])
]
Expand Down
2 changes: 1 addition & 1 deletion Sources/tart/Commands/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ struct List: AsyncParsableCommand {
print("Source\tName")

displayTable("local", try VMStorageLocal().list())
displayTable("oci", try VMStorageOCI().list())
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })

Foundation.exit(0)
} catch {
Expand Down
101 changes: 101 additions & 0 deletions Sources/tart/Commands/Prune.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import ArgumentParser
import Dispatch
import SwiftUI
import SwiftDate

struct Prune: AsyncParsableCommand {
static var configuration = CommandConfiguration(abstract: "Prune OCI and IPSW caches")

@Option(help: ArgumentHelp("Remove cache entries 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"))
var cacheBudget: UInt?

func validate() throws {
if olderThan == nil && cacheBudget == nil {
throw ValidationError("at least one criteria must be specified")
}
}

func run() async throws {
do {
// Clean up cache entries based on last accessed date
if let olderThan = olderThan {
let olderThanInterval = Int(exactly: olderThan)!.days.timeInterval
let olderThanDate = Date().addingTimeInterval(olderThanInterval)

try Prune.pruneOlderThan(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)
}

Foundation.exit(0)
} catch {
print(error)

Foundation.exit(1)
}
}

static func pruneOlderThan(olderThanDate: Date) throws {
let prunableStorages: [PrunableStorage] = [VMStorageOCI(), try IPSWCache()]
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()]
let prunables: [Prunable] = try prunableStorages
.flatMap { try $0.prunables() }
.sorted { try $0.accessDate() < $1.accessDate() }

let cacheUsedBytes = try prunables.map { try $0.sizeBytes() }.reduce(0, +)
var cacheReclaimedBytes: Int = 0

var it = prunables.makeIterator()

while (cacheUsedBytes - cacheReclaimedBytes) > cacheBudgetBytes {
guard let prunable = it.next() else {
break
}

cacheReclaimedBytes -= try prunable.sizeBytes()
try prunable.delete()
}
}

static func pruneReclaim(reclaimBytes: UInt64) throws {
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.sizeBytes() }.reduce(0, +)
if cacheUsedBytes < reclaimBytes {
return
}

var cacheReclaimedBytes: Int = 0

var it = prunables.makeIterator()

while cacheReclaimedBytes <= reclaimBytes {
guard let prunable = it.next() else {
break
}

cacheReclaimedBytes -= try prunable.sizeBytes()
try prunable.delete()
}
}
}
20 changes: 20 additions & 0 deletions Sources/tart/IPSWCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation
import Virtualization

class IPSWCache: PrunableStorage {
let baseURL: URL

init() throws {
baseURL = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
}

func locationFor(image: VZMacOSRestoreImage) -> URL {
baseURL.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
}

func prunables() throws -> [Prunable] {
try FileManager.default.contentsOfDirectory(at: baseURL, includingPropertiesForKeys: nil)
.filter { $0.lastPathComponent.hasSuffix(".ipsw")}
}
}
11 changes: 11 additions & 0 deletions Sources/tart/Prunable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

protocol PrunableStorage {
func prunables() throws -> [Prunable]
}

protocol Prunable {
func delete() throws
func accessDate() throws -> Date
func sizeBytes() throws -> Int
}
1 change: 1 addition & 0 deletions Sources/tart/Root.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
IP.self,
Pull.self,
Push.self,
Prune.self,
Delete.self,
])

Expand Down
25 changes: 25 additions & 0 deletions Sources/tart/URL+AccessDate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

extension URL {
func accessDate() throws -> Date {
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
return attrs.contentAccessDate!
}

func updateAccessDate(_ accessDate: Date = Date()) throws {
let attrs = try resourceValues(forKeys: [.contentAccessDateKey])
let modificationDate = attrs.contentAccessDate!

let times = [accessDate.asTimeval(), modificationDate.asTimeval()]
let ret = utimes(path, times)
if ret != 0 {
throw RuntimeError("utimes(2) failed: \(ret.explanation())")
}
}
}

extension Date {
func asTimeval() -> timeval {
timeval(tv_sec: timeIntervalSince1970.toUnit(.second)!, tv_usec: 0)
}
}
11 changes: 11 additions & 0 deletions Sources/tart/URL+Prunable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

extension URL: Prunable {
func delete() throws {
try FileManager.default.removeItem(at: self)
}

func sizeBytes() throws -> Int {
try resourceValues(forKeys: [.totalFileAllocatedSizeKey]).totalFileAllocatedSize!
}
}
7 changes: 2 additions & 5 deletions Sources/tart/VM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
}
}


let ipswCacheFolder = Config().tartCacheDir.appendingPathComponent("IPSWs", isDirectory: true)
try FileManager.default.createDirectory(at: ipswCacheFolder, withIntermediateDirectories: true)

let expectedIPSWLocation = ipswCacheFolder.appendingPathComponent("\(image.buildVersion).ipsw", isDirectory: false)
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)

if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
defaultLogger.appendNewLine("Using cached *.ipsw file...")
try expectedIPSWLocation.updateAccessDate()
return expectedIPSWLocation
}

Expand Down
14 changes: 13 additions & 1 deletion Sources/tart/VMDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
struct AlreadyInitializedVMDirectoryError: Error {
}

struct VMDirectory {
struct VMDirectory: Prunable {
var baseURL: URL

var configURL: URL {
Expand Down Expand Up @@ -77,4 +77,16 @@ struct VMDirectory {
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
try diskFileHandle.close()
}

func delete() throws {
try FileManager.default.removeItem(at: baseURL)
}

func accessDate() throws -> Date {
try baseURL.accessDate()
}

func sizeBytes() throws -> Int {
try configURL.sizeBytes() + diskURL.sizeBytes() + nvramURL.sizeBytes()
}
}
30 changes: 25 additions & 5 deletions Sources/tart/VMStorageOCI.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

class VMStorageOCI {
class VMStorageOCI: PrunableStorage {
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)

private func vmURL(_ name: RemoteName) -> URL {
Expand All @@ -16,6 +16,8 @@ class VMStorageOCI {

try vmDir.validate()

try vmDir.baseURL.updateAccessDate()

return vmDir
}

Expand All @@ -42,8 +44,8 @@ class VMStorageOCI {
try FileManager.default.removeItem(at: vmURL(name))
}

func list() throws -> [(String, VMDirectory)] {
var result: [(String, VMDirectory)] = Array()
func list() throws -> [(String, VMDirectory, Bool)] {
var result: [(String, VMDirectory, Bool)] = Array()

guard let enumerator = FileManager.default.enumerator(at: baseURL,
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
Expand All @@ -60,18 +62,23 @@ class VMStorageOCI {
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
var name: String

if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
if isSymlink {
name = parts.joined(separator: ":")
} else {
name = parts.joined(separator: "@")
}

result.append((name, vmDir))
result.append((name, vmDir, isSymlink))
}

return result
}

func prunables() throws -> [Prunable] {
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
}

func pull(_ name: RemoteName, registry: Registry) async throws {
defaultLogger.appendNewLine("pulling manifest...")

Expand All @@ -82,6 +89,19 @@ class VMStorageOCI {

if !exists(digestName) {
let tmpVMDir = try VMDirectory.temporary()

// Try to reclaim some cache space if we know the VM size in advance
if let uncompressedDiskSize = manifest.uncompressedDiskSize() {
let requiredCapacityBytes = UInt64(uncompressedDiskSize + 128 * 1024 * 1024)

let attrs = try tmpVMDir.baseURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
let availableCapacityBytes = UInt64(attrs.volumeAvailableCapacityForImportantUsage!)

if availableCapacityBytes < requiredCapacityBytes {
try Prune.pruneReclaim(reclaimBytes: requiredCapacityBytes - availableCapacityBytes)
}
}

try await withTaskCancellationHandler(operation: {
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
try move(digestName, from: tmpVMDir)
Expand Down
22 changes: 22 additions & 0 deletions Tests/TartTests/URLAccessDateTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import XCTest
@testable import tart

final class URLAccessDateTests: XCTestCase {
func testGetAndSetAccessTime() throws {
// Create a temporary file
let tmpDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
var tmpFile = tmpDir.appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: tmpFile.path, contents: nil)

// Ensure it's access date is different than our desired access date
let arbitraryDate = Date.init(year: 2008, month: 09, day: 28, hour: 23, minute: 15)
XCTAssertNotEqual(arbitraryDate, try tmpFile.accessDate())

// Set our desired access date for a file
try tmpFile.updateAccessDate(arbitraryDate)

// Ensure the access date has changed to our value
tmpFile.removeCachedResourceValue(forKey: .contentAccessDateKey)
XCTAssertEqual(arbitraryDate, try tmpFile.accessDate())
}
}

0 comments on commit 4406813

Please sign in to comment.