mirror of
https://github.com/cirruslabs/tart.git
synced 2026-10-22 03:24:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732c8244a4 | ||
|
|
9e69c8c161 | ||
|
|
e4ac2275b9 | ||
|
|
1a1f19e169 | ||
|
|
fea916dacc | ||
|
|
b1be9730c7 | ||
|
|
14059a1d6f | ||
|
|
440681320a |
+2
-2
@@ -10,7 +10,7 @@ task:
|
|||||||
name: Build
|
name: Build
|
||||||
only_if: $CIRRUS_TAG == ''
|
only_if: $CIRRUS_TAG == ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
build_script: swift build --product tart
|
build_script: swift build --product tart
|
||||||
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
sign_script: codesign --sign - --entitlements Resources/tart.entitlements --force .build/debug/tart
|
||||||
binary_artifacts:
|
binary_artifacts:
|
||||||
@@ -20,7 +20,7 @@ task:
|
|||||||
name: Release
|
name: Release
|
||||||
only_if: $CIRRUS_TAG != ''
|
only_if: $CIRRUS_TAG != ''
|
||||||
macos_instance:
|
macos_instance:
|
||||||
image: ghcr.io/cirruslabs/macos-monterey-xcode:14
|
image: ghcr.io/cirruslabs/macos-ventura-xcode:latest
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
GITHUB_TOKEN: ENCRYPTED[!98ace8259c6024da912c14d5a3c5c6aac186890a8d4819fad78f3e0c41a4e0cd3a2537dd6e91493952fb056fa434be7c!]
|
||||||
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
GORELEASER_KEY: ENCRYPTED[!9b80b6ef684ceaf40edd4c7af93014ee156c8aba7e6e5795f41c482729887b5c31f36b651491d790f1f668670888d9fd!]
|
||||||
|
|||||||
@@ -126,6 +126,15 @@
|
|||||||
"version" : "0.9.2"
|
"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",
|
"identity" : "xctest-dynamic-overlay",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.9.2"),
|
.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/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/apple/swift-algorithms", from: "1.0.0"),
|
||||||
|
.package(url: "https://github.com/malcommac/SwiftDate", from: "6.3.1")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.executableTarget(name: "tart", dependencies: [
|
.executableTarget(name: "tart", dependencies: [
|
||||||
@@ -23,6 +24,7 @@ let package = Package(
|
|||||||
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
.product(name: "AsyncHTTPClient", package: "async-http-client"),
|
||||||
.product(name: "Dynamic", package: "Dynamic"),
|
.product(name: "Dynamic", package: "Dynamic"),
|
||||||
.product(name: "Parsing", package: "swift-parsing"),
|
.product(name: "Parsing", package: "swift-parsing"),
|
||||||
|
.product(name: "SwiftDate", package: "SwiftDate"),
|
||||||
]),
|
]),
|
||||||
.testTarget(name: "TartTests", dependencies: ["tart"])
|
.testTarget(name: "TartTests", dependencies: ["tart"])
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ struct Clone: AsyncParsableCommand {
|
|||||||
@Argument(help: "new VM name")
|
@Argument(help: "new VM name")
|
||||||
var newName: String
|
var newName: String
|
||||||
|
|
||||||
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
|
var insecure: Bool = false
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if newName.contains("/") {
|
if newName.contains("/") {
|
||||||
throw ValidationError("<new-name> should be a local name")
|
throw ValidationError("<new-name> should be a local name")
|
||||||
@@ -24,7 +27,7 @@ struct Clone: AsyncParsableCommand {
|
|||||||
|
|
||||||
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
if let remoteName = try? RemoteName(sourceName), !ociStorage.exists(remoteName) {
|
||||||
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
// Pull the VM in case it's OCI-based and doesn't exist locally yet
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
try await ociStorage.pull(remoteName, registry: registry)
|
try await ociStorage.pull(remoteName, registry: registry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,21 @@ struct IP: AsyncParsableCommand {
|
|||||||
do {
|
do {
|
||||||
let vmDir = try VMStorageLocal().open(name)
|
let vmDir = try VMStorageLocal().open(name)
|
||||||
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig.init(fromURL: vmDir.configURL)
|
||||||
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
|
||||||
guard let ip = try await IP.resolveIP(vmConfig, secondsToWait: wait) else {
|
guard let ipViaDHCP = try await IP.resolveIP(vmMACAddress, secondsToWait: wait) else {
|
||||||
print("no IP address found, is your VM running?")
|
print("no IP address found, is your VM running?")
|
||||||
|
|
||||||
Foundation.exit(1)
|
Foundation.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
print(ip)
|
if let ipViaARP = try ARPCache.ResolveMACAddress(macAddress: vmMACAddress), ipViaARP != ipViaDHCP {
|
||||||
|
fputs("WARNING: DHCP lease and ARP cache entries for MAC address \(vmMACAddress) differ: "
|
||||||
|
+ "got \(ipViaDHCP) and \(ipViaARP) respectively, consider reporting this case to"
|
||||||
|
+ " https://github.com/cirruslabs/tart/issues/172\n", stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
print(ipViaDHCP)
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -33,12 +40,11 @@ struct IP: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static public func resolveIP(_ config: VMConfig, secondsToWait: UInt16) async throws -> IPv4Address? {
|
static public func resolveIP(_ vmMACAddress: MACAddress, secondsToWait: UInt16) async throws -> IPv4Address? {
|
||||||
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
let waitUntil = Calendar.current.date(byAdding: .second, value: Int(secondsToWait), to: Date.now)!
|
||||||
let vmMacAddress = MACAddress(fromString: config.macAddress.string)!
|
|
||||||
|
|
||||||
repeat {
|
repeat {
|
||||||
if let ip = try Leases().resolveMACAddress(macAddress: vmMacAddress) {
|
if let ip = try Leases().resolveMACAddress(macAddress: vmMACAddress) {
|
||||||
return ip
|
return ip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct List: AsyncParsableCommand {
|
|||||||
print("Source\tName")
|
print("Source\tName")
|
||||||
|
|
||||||
displayTable("local", try VMStorageLocal().list())
|
displayTable("local", try VMStorageLocal().list())
|
||||||
displayTable("oci", try VMStorageOCI().list())
|
displayTable("oci", try VMStorageOCI().list().map { (name, vmDir, _) in (name, vmDir) })
|
||||||
|
|
||||||
Foundation.exit(0)
|
Foundation.exit(0)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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?
|
||||||
|
|
||||||
|
@Flag(help: .hidden)
|
||||||
|
var gc: Bool = false
|
||||||
|
|
||||||
|
func validate() throws {
|
||||||
|
if olderThan == nil && cacheBudget == nil {
|
||||||
|
throw ValidationError("at least one criteria must be specified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() async throws {
|
||||||
|
do {
|
||||||
|
if gc {
|
||||||
|
try VMStorageOCI().gc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ struct Pull: AsyncParsableCommand {
|
|||||||
@Argument(help: "remote VM name")
|
@Argument(help: "remote VM name")
|
||||||
var remoteName: String
|
var remoteName: String
|
||||||
|
|
||||||
|
@Flag(help: "connect to the OCI registry via insecure HTTP protocol")
|
||||||
|
var insecure: Bool = false
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
do {
|
do {
|
||||||
// Be more liberal when accepting local image as argument,
|
// Be more liberal when accepting local image as argument,
|
||||||
@@ -19,7 +22,7 @@ struct Pull: AsyncParsableCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let remoteName = try RemoteName(remoteName)
|
let remoteName = try RemoteName(remoteName)
|
||||||
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace)
|
let registry = try Registry(host: remoteName.host, namespace: remoteName.namespace, insecure: insecure)
|
||||||
|
|
||||||
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
defaultLogger.appendNewLine("pulling \(remoteName)...")
|
||||||
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
import Virtualization
|
||||||
|
|
||||||
|
struct ARPCommandFailedError: Error, CustomStringConvertible {
|
||||||
|
var terminationReason: Process.TerminationReason
|
||||||
|
var terminationStatus: Int32
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
var reason: String
|
||||||
|
|
||||||
|
switch terminationReason {
|
||||||
|
case .exit:
|
||||||
|
reason = "exit code \(terminationStatus)"
|
||||||
|
case .uncaughtSignal:
|
||||||
|
reason = "uncaught signal"
|
||||||
|
default:
|
||||||
|
reason = "unknown reason"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "arp command failed: \(reason)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCommandYieldedInvalidOutputError: Error, CustomStringConvertible {
|
||||||
|
var explanation: String
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"arp command yielded invalid output: \(explanation)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCacheInternalError: Error, CustomStringConvertible {
|
||||||
|
var explanation: String
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
"ARPCache internal error: \(explanation)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ARPCache {
|
||||||
|
static func ResolveMACAddress(macAddress: MACAddress, bridgeOnly: Bool = true) throws -> IPv4Address? {
|
||||||
|
let process = Process.init()
|
||||||
|
process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp")
|
||||||
|
process.arguments = ["-an"]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
process.standardInput = FileHandle.nullDevice
|
||||||
|
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
if !(process.terminationReason == .exit && process.terminationStatus == 0) {
|
||||||
|
throw ARPCommandFailedError(
|
||||||
|
terminationReason: process.terminationReason,
|
||||||
|
terminationStatus: process.terminationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let rawLines = try pipe.fileHandleForReading.readToEnd() else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "empty output")
|
||||||
|
}
|
||||||
|
let lines = String(decoding: rawLines, as: UTF8.self)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.components(separatedBy: "\n")
|
||||||
|
|
||||||
|
// Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html
|
||||||
|
let regex = try NSRegularExpression(pattern: #"^.* \((?<ip>.*)\) at (?<mac>.*) on (?<interface>.*) .*$"#)
|
||||||
|
|
||||||
|
for line in lines {
|
||||||
|
let nsLineRange = NSRange(line.startIndex..<line.endIndex, in: line)
|
||||||
|
|
||||||
|
guard let match = regex.firstMatch(in: line, range: nsLineRange) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "unparseable entry \"\(line)\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawIP = try match.getCaptureGroup(name: "ip", for: line)
|
||||||
|
guard let ip = IPv4Address(rawIP) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse IPv4 address \(rawIP)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawMAC = try match.getCaptureGroup(name: "mac", for: line)
|
||||||
|
if rawMAC == "(incomplete)" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let mac = MACAddress(fromString: rawMAC) else {
|
||||||
|
throw ARPCommandYieldedInvalidOutputError(explanation: "failed to parse MAC address \(rawMAC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let interface = try match.getCaptureGroup(name: "interface", for: line)
|
||||||
|
if bridgeOnly && !interface.starts(with: "bridge") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if macAddress == mac {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSTextCheckingResult {
|
||||||
|
func getCaptureGroup(name: String, for string: String) throws -> String {
|
||||||
|
let nsRange = self.range(withName: name)
|
||||||
|
|
||||||
|
if nsRange.location == NSNotFound {
|
||||||
|
throw ARPCacheInternalError(explanation: "attempted to retrieve non-existent named capture group \(name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let range = Range.init(nsRange, in: string) else {
|
||||||
|
throw ARPCacheInternalError(explanation: "failed to convert NSRange to Range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(string[range])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,6 @@ struct MACAddress: Equatable, Hashable, CustomStringConvertible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
return String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
String(format: "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ struct Root: AsyncParsableCommand {
|
|||||||
IP.self,
|
IP.self,
|
||||||
Pull.self,
|
Pull.self,
|
||||||
Push.self,
|
Push.self,
|
||||||
|
Prune.self,
|
||||||
Delete.self,
|
Delete.self,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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: Int(timeIntervalSince1970), tv_usec: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,14 +55,11 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let expectedIPSWLocation = try IPSWCache().locationFor(image: image)
|
||||||
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)
|
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
if FileManager.default.fileExists(atPath: expectedIPSWLocation.path) {
|
||||||
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
defaultLogger.appendNewLine("Using cached *.ipsw file...")
|
||||||
|
try expectedIPSWLocation.updateAccessDate()
|
||||||
return expectedIPSWLocation
|
return expectedIPSWLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,13 +117,15 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Read VM's compressed disk as chunks
|
// Read VM's compressed disk as chunks
|
||||||
// and sequentially upload them as blobs
|
// and sequentially upload them as blobs
|
||||||
let disk = try FileHandle(forReadingFrom: diskURL)
|
let mappedDisk = try Data(contentsOf: diskURL, options: [.alwaysMapped])
|
||||||
var diskReadBytes: UInt64 = 0
|
let mappedDiskSize = mappedDisk.count
|
||||||
let compressingFilter = try InputFilter<Data>(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { _ in
|
var mappedDiskReadOffset = 0
|
||||||
let data = try disk.read(upToCount: Self.bufferSizeBytes)
|
let compressingFilter = try InputFilter(.compress, using: .lz4, bufferCapacity: Self.bufferSizeBytes) { (length: Int) -> Data? in
|
||||||
diskReadBytes += UInt64(data?.count ?? 0)
|
let bytesRead = min(length, mappedDiskSize - mappedDiskReadOffset)
|
||||||
|
let data = mappedDisk.subdata(in: mappedDiskReadOffset ..< mappedDiskReadOffset + bytesRead)
|
||||||
|
mappedDiskReadOffset += bytesRead
|
||||||
|
|
||||||
progress.completedUnitCount += Int64(data?.count ?? 0)
|
progress.completedUnitCount = Int64(mappedDiskReadOffset)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
@@ -145,7 +147,7 @@ extension VMDirectory {
|
|||||||
let manifest = OCIManifest(
|
let manifest = OCIManifest(
|
||||||
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest),
|
||||||
layers: layers,
|
layers: layers,
|
||||||
uncompressedDiskSize: diskReadBytes
|
uncompressedDiskSize: UInt64(mappedDiskReadOffset)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manifest
|
// Manifest
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct UninitializedVMDirectoryError: Error {
|
|||||||
struct AlreadyInitializedVMDirectoryError: Error {
|
struct AlreadyInitializedVMDirectoryError: Error {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VMDirectory {
|
struct VMDirectory: Prunable {
|
||||||
var baseURL: URL
|
var baseURL: URL
|
||||||
|
|
||||||
var configURL: URL {
|
var configURL: URL {
|
||||||
@@ -20,6 +20,10 @@ struct VMDirectory {
|
|||||||
baseURL.appendingPathComponent("nvram.bin")
|
baseURL.appendingPathComponent("nvram.bin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var explicitlyPulledMark: URL {
|
||||||
|
baseURL.appendingPathComponent(".explicitly-pulled")
|
||||||
|
}
|
||||||
|
|
||||||
var name: String {
|
var name: String {
|
||||||
baseURL.lastPathComponent
|
baseURL.lastPathComponent
|
||||||
}
|
}
|
||||||
@@ -77,4 +81,24 @@ struct VMDirectory {
|
|||||||
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
try diskFileHandle.truncate(atOffset: UInt64(sizeGB) * 1000 * 1000 * 1000)
|
||||||
try diskFileHandle.close()
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markExplicitlyPulled() {
|
||||||
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExplicitlyPulled() -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: explicitlyPulledMark.path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
class VMStorageOCI {
|
class VMStorageOCI: PrunableStorage {
|
||||||
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
let baseURL = Config().tartCacheDir.appendingPathComponent("OCIs", isDirectory: true)
|
||||||
|
|
||||||
private func vmURL(_ name: RemoteName) -> URL {
|
private func vmURL(_ name: RemoteName) -> URL {
|
||||||
@@ -16,6 +16,8 @@ class VMStorageOCI {
|
|||||||
|
|
||||||
try vmDir.validate()
|
try vmDir.validate()
|
||||||
|
|
||||||
|
try vmDir.baseURL.updateAccessDate()
|
||||||
|
|
||||||
return vmDir
|
return vmDir
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,10 +42,48 @@ class VMStorageOCI {
|
|||||||
|
|
||||||
func delete(_ name: RemoteName) throws {
|
func delete(_ name: RemoteName) throws {
|
||||||
try FileManager.default.removeItem(at: vmURL(name))
|
try FileManager.default.removeItem(at: vmURL(name))
|
||||||
|
try gc()
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() throws -> [(String, VMDirectory)] {
|
func gc() throws {
|
||||||
var result: [(String, VMDirectory)] = Array()
|
var refCounts = Dictionary<URL, UInt>()
|
||||||
|
|
||||||
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
|
includingPropertiesForKeys: [.isSymbolicLinkKey]) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for case let foundURL as URL in enumerator {
|
||||||
|
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||||
|
|
||||||
|
// Perform garbage collection for tag-based images
|
||||||
|
// with broken outgoing references
|
||||||
|
if isSymlink && foundURL == foundURL.resolvingSymlinksInPath() {
|
||||||
|
try FileManager.default.removeItem(at: foundURL)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let vmDir = VMDirectory(baseURL: foundURL.resolvingSymlinksInPath())
|
||||||
|
if !vmDir.initialized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
refCounts[vmDir.baseURL] = (refCounts[vmDir.baseURL] ?? 0) + (isSymlink ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform garbage collection for digest-based images
|
||||||
|
// with no incoming references
|
||||||
|
for (baseURL, incRefCount) in refCounts {
|
||||||
|
let vmDir = VMDirectory(baseURL: baseURL)
|
||||||
|
|
||||||
|
if !vmDir.isExplicitlyPulled() && incRefCount == 0 {
|
||||||
|
try FileManager.default.removeItem(at: baseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func list() throws -> [(String, VMDirectory, Bool)] {
|
||||||
|
var result: [(String, VMDirectory, Bool)] = Array()
|
||||||
|
|
||||||
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
guard let enumerator = FileManager.default.enumerator(at: baseURL,
|
||||||
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
includingPropertiesForKeys: [.isSymbolicLinkKey], options: [.producesRelativePathURLs]) else {
|
||||||
@@ -60,28 +100,46 @@ class VMStorageOCI {
|
|||||||
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
let parts = [foundURL.deletingLastPathComponent().relativePath, foundURL.lastPathComponent]
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
if try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink! {
|
let isSymlink = try foundURL.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink!
|
||||||
|
if isSymlink {
|
||||||
name = parts.joined(separator: ":")
|
name = parts.joined(separator: ":")
|
||||||
} else {
|
} else {
|
||||||
name = parts.joined(separator: "@")
|
name = parts.joined(separator: "@")
|
||||||
}
|
}
|
||||||
|
|
||||||
result.append((name, vmDir))
|
result.append((name, vmDir, isSymlink))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prunables() throws -> [Prunable] {
|
||||||
|
try list().filter { (_, _, isSymlink) in !isSymlink }.map { (_, vmDir, _) in vmDir }
|
||||||
|
}
|
||||||
|
|
||||||
func pull(_ name: RemoteName, registry: Registry) async throws {
|
func pull(_ name: RemoteName, registry: Registry) async throws {
|
||||||
defaultLogger.appendNewLine("pulling manifest...")
|
defaultLogger.appendNewLine("pulling manifest...")
|
||||||
|
|
||||||
let (manifest, _) = try await registry.pullManifest(reference: name.reference.value)
|
let (manifest, manifestData) = try await registry.pullManifest(reference: name.reference.value)
|
||||||
|
|
||||||
var digestName = RemoteName(host: name.host, namespace: name.namespace,
|
let digestName = RemoteName(host: name.host, namespace: name.namespace,
|
||||||
reference: Reference(digest: try manifest.digest()))
|
reference: Reference(digest: Digest.hash(manifestData)))
|
||||||
|
|
||||||
if !exists(digestName) {
|
if !exists(digestName) {
|
||||||
let tmpVMDir = try VMDirectory.temporary()
|
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 withTaskCancellationHandler(operation: {
|
||||||
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
try await tmpVMDir.pullFromRegistry(registry: registry, manifest: manifest)
|
||||||
try move(digestName, from: tmpVMDir)
|
try move(digestName, from: tmpVMDir)
|
||||||
@@ -93,8 +151,12 @@ class VMStorageOCI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if name != digestName {
|
if name != digestName {
|
||||||
// Overwrite the old symbolic link
|
// Create new or overwrite the old symbolic link
|
||||||
try link(from: digestName, to: name)
|
try link(from: digestName, to: name)
|
||||||
|
} else {
|
||||||
|
// Ensure that images pulled by content digest
|
||||||
|
// are excluded from garbage collection
|
||||||
|
VMDirectory(baseURL: vmURL(name)).markExplicitlyPulled()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +166,8 @@ class VMStorageOCI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
try FileManager.default.createSymbolicLink(at: vmURL(to), withDestinationURL: vmURL(from))
|
||||||
|
|
||||||
|
try gc()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class ScreenSharingVNC: VNC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func waitForURL() async throws -> URL {
|
func waitForURL() async throws -> URL {
|
||||||
let ip = try await IP.resolveIP(vmConfig, secondsToWait: 60)
|
let vmMACAddress = MACAddress(fromString: vmConfig.macAddress.string)!
|
||||||
|
let ip = try await IP.resolveIP(vmMACAddress, secondsToWait: 60)
|
||||||
|
|
||||||
if let ip = ip {
|
if let ip = ip {
|
||||||
return URL(string: "vnc://\(ip)")!
|
return URL(string: "vnc://\(ip)")!
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user