diff --git a/Sources/tart/Commands/Get.swift b/Sources/tart/Commands/Get.swift index 594f0a4..91812ed 100644 --- a/Sources/tart/Commands/Get.swift +++ b/Sources/tart/Commands/Get.swift @@ -31,7 +31,8 @@ struct Get: AsyncParsableCommand { OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + // ASIF capacity lookup can fail while a running VM holds the disk open. + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, DiskFormat: vmConfig.diskFormat.rawValue, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { String(format: "%.3f", Float($0) / 1000 / 1000 / 1000) diff --git a/Sources/tart/Commands/List.swift b/Sources/tart/Commands/List.swift index 28e67bf..7a0ac23 100644 --- a/Sources/tart/Commands/List.swift +++ b/Sources/tart/Commands/List.swift @@ -42,7 +42,8 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "local", Name: name, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + // ASIF capacity lookup can fail while a running VM holds the disk open. + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), @@ -56,7 +57,7 @@ struct List: AsyncParsableCommand { try VMInfo( Source: "OCI", Name: name, - Disk: HumanReadableByteCount(try vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, + Disk: HumanReadableByteCount(try? vmDir.diskSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Size: HumanReadableByteCount(try vmDir.allocatedSizeBytes()) { $0 / 1000 / 1000 / 1000 }, Accessed: formatAccessDate(try vmDir.accessDate()), Running: vmDir.running(), diff --git a/Sources/tart/Formatter/HumanReadableByteCount.swift b/Sources/tart/Formatter/HumanReadableByteCount.swift index d287b76..fcc5b0d 100644 --- a/Sources/tart/Formatter/HumanReadableByteCount.swift +++ b/Sources/tart/Formatter/HumanReadableByteCount.swift @@ -1,15 +1,19 @@ import Foundation struct HumanReadableByteCount: Encodable, CustomStringConvertible { - private let byteCount: Int + private let byteCount: Int? private let jsonValue: any Encodable - init(_ byteCount: Int, encodedAs: (Int) -> JSONValue) { + init(_ byteCount: Int?, encodedAs: (Int) -> JSONValue) { self.byteCount = byteCount - self.jsonValue = encodedAs(byteCount) + self.jsonValue = byteCount.map(encodedAs) } var description: String { + guard let byteCount else { + return "-" + } + let formatter = MeasurementFormatter() formatter.unitOptions = .naturalScale formatter.unitStyle = .medium diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 48e3faf..7f5ad83 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -469,10 +469,6 @@ struct VMDirectory: Prunable { } } - func diskSizeGB() throws -> Int { - try diskSizeBytes() / 1000 / 1000 / 1000 - } - func markExplicitlyPulled() { FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil) } diff --git a/Tests/TartTests/CommandBehaviorTests.swift b/Tests/TartTests/CommandBehaviorTests.swift index da2ce98..c579b0b 100644 --- a/Tests/TartTests/CommandBehaviorTests.swift +++ b/Tests/TartTests/CommandBehaviorTests.swift @@ -4,6 +4,85 @@ import XCTest @testable import tart final class CommandBehaviorTests: XCTestCase { + func testListSurvivesUnavailableDiskCapacity() async throws { + try await withTemporaryTartHome { + let previousPath = try installUnavailableDiskutil() + defer { restoreEnvironment("PATH", to: previousPath) } + + let local = try VMStorageLocal() + let oci = try VMStorageOCI() + for (name, diskFormat) in [("unavailable", DiskImageFormat.asif), ("healthy", .raw)] { + let remoteName = try RemoteName("example.com/org/\(name):latest") + for vmDir in [try local.create(name), try oci.create(remoteName)] { + var vmConfig = config() + vmConfig.diskFormat = diskFormat + try vmConfig.save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + // The diskutil stub simulates a locked ASIF disk without needing a running VM. + XCTAssertTrue(FileManager.default.createFile( + atPath: vmDir.diskURL.path, + contents: Data(repeating: 0, count: 4096) + )) + if diskFormat == .asif { + XCTAssertThrowsError(try vmDir.diskSizeBytes()) + } + } + } + + for sourceArguments in [[], ["--source", "local"], ["--source", "oci"]] { + let json = try await commandOutput(List.self, sourceArguments + ["--format", "json"]) + let rows = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]]) + XCTAssertEqual(rows.count, sourceArguments.isEmpty ? 4 : 2) + for row in rows { + let name = try XCTUnwrap(row["Name"] as? String) + if name.contains("unavailable") { + XCTAssertTrue(row["Disk"] is NSNull) + } else { + XCTAssertEqual(row["Disk"] as? Int, 0) + } + XCTAssertEqual(row["State"] as? String, "stopped") + XCTAssertEqual(row["Running"] as? Bool, false) + } + + let text = try await commandOutput(List.self, sourceArguments) + XCTAssertTrue(text.contains("unavailable")) + XCTAssertTrue(text.contains("healthy")) + XCTAssertTrue(text.contains("-")) + + let quiet = try await commandOutput(List.self, sourceArguments + ["--quiet"]) + XCTAssertEqual(quiet.split(separator: "\n").map(String.init), rows.compactMap { $0["Name"] as? String }) + } + } + } + + func testGetSurvivesUnavailableDiskCapacity() async throws { + try await withTemporaryTartHome { + let previousPath = try installUnavailableDiskutil() + defer { restoreEnvironment("PATH", to: previousPath) } + + let vmDir = try VMStorageLocal().create("unavailable") + var vmConfig = config() + vmConfig.diskFormat = .asif + try vmConfig.save(toURL: vmDir.configURL) + XCTAssertTrue(FileManager.default.createFile(atPath: vmDir.nvramURL.path, contents: Data())) + XCTAssertTrue(FileManager.default.createFile( + atPath: vmDir.diskURL.path, + contents: Data(repeating: 0, count: 4096) + )) + XCTAssertThrowsError(try vmDir.diskSizeBytes()) + + let json = try await commandOutput(Get.self, ["unavailable", "--format", "json"]) + let info = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + XCTAssertTrue(info["Disk"] is NSNull) + XCTAssertEqual(info["DiskFormat"] as? String, "asif") + XCTAssertEqual(info["State"] as? String, "stopped") + + let text = try await commandOutput(Get.self, ["unavailable"]) + XCTAssertTrue(text.contains("asif")) + XCTAssertTrue(text.contains("-")) + } + } + func testNoUSBAccessoriesDoesNotEnableSuspendable() throws { try withTemporaryTartHome { let vmDir = try VMStorageLocal().create("no-usb-accessories") @@ -130,6 +209,44 @@ final class CommandBehaviorTests: XCTestCase { ) } + private func installUnavailableDiskutil() throws -> String? { + let binDirectory = try temporaryDirectory() + let diskutilURL = binDirectory.appendingPathComponent("diskutil") + let script = """ + #!/bin/sh + echo 'Resource temporarily unavailable' >&2 + exit 1 + """ + try script.write(to: diskutilURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: diskutilURL.path) + let previousPath = ProcessInfo.processInfo.environment["PATH"] + setenv("PATH", binDirectory.path, 1) + return previousPath + } + + private func commandOutput( + _ commandType: Command.Type, + _ arguments: [String] + ) async throws -> String { + let outputURL = try temporaryDirectory().appendingPathComponent("stdout") + XCTAssertTrue(FileManager.default.createFile(atPath: outputURL.path, contents: nil)) + let output = try FileHandle(forWritingTo: outputURL) + defer { try? output.close() } + + fflush(stdout) + let savedStdout = dup(STDOUT_FILENO) + defer { + fflush(stdout) + dup2(savedStdout, STDOUT_FILENO) + close(savedStdout) + } + dup2(output.fileDescriptor, STDOUT_FILENO) + var command = try Command.parseAsRoot(arguments) as! Command + try await command.run() + fflush(stdout) + return try String(contentsOf: outputURL, encoding: .utf8) + } + private func withTemporaryTartHome(_ body: () throws -> Void) throws { let home = try temporaryDirectory() let previousHome = ProcessInfo.processInfo.environment["TART_HOME"] diff --git a/Tests/TartTests/HumanReadableByteCountTests.swift b/Tests/TartTests/HumanReadableByteCountTests.swift index 398158e..3e30a61 100644 --- a/Tests/TartTests/HumanReadableByteCountTests.swift +++ b/Tests/TartTests/HumanReadableByteCountTests.swift @@ -3,6 +3,13 @@ import XCTest @testable import tart final class HumanReadableByteCountTests: XCTestCase { + func testUnknownByteCount() throws { + let unknown = HumanReadableByteCount(nil) { $0 / 1000 / 1000 / 1000 } + + XCTAssertEqual(unknown.description, "-") + XCTAssertEqual(String(data: try JSONEncoder().encode(unknown), encoding: .utf8), "null") + } + func testTextAndJSONRepresentations() throws { let integer = HumanReadableByteCount(51_400_000_000) { _ in 51 } let string = HumanReadableByteCount(17_234_000_000) { _ in "17.234" }