mirror of https://github.com/cirruslabs/tart.git
Refactor "diskutil create" and "diskutil info" into a separate class (#1172)
* Show true ASIF disk sizes * Use older sizeGB()
This commit is contained in:
parent
20dcfc83f2
commit
44892c5def
|
|
@ -11,6 +11,8 @@ task:
|
||||||
build_script:
|
build_script:
|
||||||
- swift build
|
- swift build
|
||||||
test_script:
|
test_script:
|
||||||
|
# Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil"
|
||||||
|
- export PATH=$PATH:/usr/sbin
|
||||||
- swift test
|
- swift test
|
||||||
integration_test_script:
|
integration_test_script:
|
||||||
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
- codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ImageInfo: Codable {
|
||||||
|
let sizeInfo: SizeInfo?
|
||||||
|
let size: UInt64?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case sizeInfo = "Size Info"
|
||||||
|
case size = "Size"
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalBytes() throws -> Int {
|
||||||
|
if let totalBytes = self.sizeInfo?.totalBytes {
|
||||||
|
return Int(totalBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let size = self.size {
|
||||||
|
return Int(size)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw RuntimeError.Generic("Could not find size information in disk image info")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SizeInfo: Codable {
|
||||||
|
let totalBytes: UInt64?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalBytes = "Total Bytes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Diskutil {
|
||||||
|
static func imageCreate(diskURL: URL, sizeGB: UInt16) throws {
|
||||||
|
do {
|
||||||
|
_ = try run([
|
||||||
|
"image", "create", "blank",
|
||||||
|
"--format", "ASIF",
|
||||||
|
"--size", "\(sizeGB)G",
|
||||||
|
"--volumeName", "Tart",
|
||||||
|
diskURL.path
|
||||||
|
])
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func imageInfo(_ diskURL: URL) throws -> ImageInfo {
|
||||||
|
do {
|
||||||
|
let (stdoutData, _) = try run([
|
||||||
|
"image", "info", "--plist",
|
||||||
|
diskURL.path
|
||||||
|
])
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try PropertyListDecoder().decode(ImageInfo.self, from: stdoutData)
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("Failed to parse \"diskutil image info --plist\" output: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func run(_ arguments: [String]) throws -> (Data, Data) {
|
||||||
|
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||||
|
throw RuntimeError.Generic("\"diskutil\" binary is not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = diskutilURL
|
||||||
|
process.arguments = arguments
|
||||||
|
|
||||||
|
let stdoutPipe = Pipe()
|
||||||
|
process.standardOutput = stdoutPipe
|
||||||
|
let stderrPipe = Pipe()
|
||||||
|
process.standardError = stderrPipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed: \(error)")
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
|
if process.terminationStatus != 0 {
|
||||||
|
let stdoutString = String(data: stdoutData, encoding: .utf8) ?? ""
|
||||||
|
let stderrString = String(data: stderrData, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
|
throw RuntimeError.Generic("\"\(arguments.joined(separator: " "))\" failed with exit code \(process.terminationStatus): \(firstNonEmptyLine(stderrString, stdoutString))")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (stdoutData, stderrData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func firstNonEmptyLine(_ outputs: String...) -> String {
|
||||||
|
for output in outputs {
|
||||||
|
for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||||
|
if !line.isEmpty {
|
||||||
|
return String(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,25 +2,6 @@ import Foundation
|
||||||
import Virtualization
|
import Virtualization
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Disk Image Info Structures
|
|
||||||
struct DiskImageInfo: Codable {
|
|
||||||
let sizeInfo: SizeInfo?
|
|
||||||
let size: UInt64?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case sizeInfo = "Size Info"
|
|
||||||
case size = "Size"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SizeInfo: Codable {
|
|
||||||
let totalBytes: UInt64?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case totalBytes = "Total Bytes"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct VMDirectory: Prunable {
|
struct VMDirectory: Prunable {
|
||||||
enum State: String {
|
enum State: String {
|
||||||
case Running = "running"
|
case Running = "running"
|
||||||
|
|
@ -201,69 +182,28 @@ struct VMDirectory: Prunable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
|
private func resizeASIFDisk(_ sizeGB: UInt16) throws {
|
||||||
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
|
||||||
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, get current disk image info to check current size
|
|
||||||
let infoProcess = Process()
|
|
||||||
infoProcess.executableURL = diskutilURL
|
|
||||||
infoProcess.arguments = ["image", "info", "--plist", diskURL.path]
|
|
||||||
|
|
||||||
let infoPipe = Pipe()
|
|
||||||
infoProcess.standardOutput = infoPipe
|
|
||||||
infoProcess.standardError = infoPipe
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try infoProcess.run()
|
let diskImageInfo = try Diskutil.imageInfo(diskURL)
|
||||||
infoProcess.waitUntilExit()
|
|
||||||
|
|
||||||
let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
|
|
||||||
if infoProcess.terminationStatus != 0 {
|
|
||||||
let output = String(data: infoData, encoding: .utf8) ?? "Unknown error"
|
|
||||||
throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the plist using PropertyListDecoder
|
|
||||||
do {
|
|
||||||
let diskImageInfo = try PropertyListDecoder().decode(DiskImageInfo.self, from: infoData)
|
|
||||||
|
|
||||||
// Extract current size from the decoded structure
|
|
||||||
var currentSizeBytes: UInt64?
|
|
||||||
|
|
||||||
// Try to get size from Size Info -> Total Bytes first
|
|
||||||
if let totalBytes = diskImageInfo.sizeInfo?.totalBytes {
|
|
||||||
currentSizeBytes = totalBytes
|
|
||||||
} else if let size = diskImageInfo.size {
|
|
||||||
// Fallback to top-level Size field
|
|
||||||
currentSizeBytes = size
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let currentSizeBytes = currentSizeBytes else {
|
|
||||||
throw RuntimeError.FailedToResizeDisk("Could not find size information in disk image info")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
let currentSizeBytes = try diskImageInfo.totalBytes()
|
||||||
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
|
||||||
if desiredSizeBytes < currentSizeBytes {
|
if desiredSizeBytes < currentSizeBytes {
|
||||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes))
|
||||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes))
|
||||||
throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " +
|
|
||||||
|
throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " +
|
||||||
"than the current disk size of \(currentLengthHuman)")
|
"than the current disk size of \(currentLengthHuman)")
|
||||||
} else if desiredSizeBytes > currentSizeBytes {
|
} else if desiredSizeBytes > currentSizeBytes {
|
||||||
// Resize the ASIF disk image using diskutil
|
// Resize the ASIF disk image using diskutil
|
||||||
try performASIFResize(sizeGB)
|
try performASIFResize(sizeGB)
|
||||||
}
|
} else {
|
||||||
// If sizes are equal, no action needed
|
// If sizes are equal, no action needed
|
||||||
|
}
|
||||||
} catch let error as RuntimeError {
|
} catch let error as RuntimeError {
|
||||||
throw error
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output"
|
throw RuntimeError.FailedToResizeDisk("\(error)")
|
||||||
throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,7 +244,7 @@ struct VMDirectory: Prunable {
|
||||||
case .raw:
|
case .raw:
|
||||||
try createRawDisk(sizeGB: sizeGB)
|
try createRawDisk(sizeGB: sizeGB)
|
||||||
case .asif:
|
case .asif:
|
||||||
try createASIFDisk(sizeGB: sizeGB)
|
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -318,38 +258,6 @@ struct VMDirectory: Prunable {
|
||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createASIFDisk(sizeGB: UInt16) throws {
|
|
||||||
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
|
||||||
throw RuntimeError.FailedToCreateDisk("diskutil not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
let process = Process()
|
|
||||||
process.executableURL = diskutilURL
|
|
||||||
process.arguments = [
|
|
||||||
"image", "create", "blank",
|
|
||||||
"--format", "ASIF",
|
|
||||||
"--size", "\(sizeGB)G",
|
|
||||||
"--volumeName", "Tart",
|
|
||||||
diskURL.path
|
|
||||||
]
|
|
||||||
|
|
||||||
let pipe = Pipe()
|
|
||||||
process.standardOutput = pipe
|
|
||||||
process.standardError = pipe
|
|
||||||
|
|
||||||
do {
|
|
||||||
try process.run()
|
|
||||||
process.waitUntilExit()
|
|
||||||
|
|
||||||
if process.terminationStatus != 0 {
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
||||||
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
||||||
throw RuntimeError.FailedToCreateDisk("Failed to create ASIF disk image: \(output)")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw RuntimeError.FailedToCreateDisk("Failed to execute diskutil: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func delete() throws {
|
func delete() throws {
|
||||||
let lock = try lock()
|
let lock = try lock()
|
||||||
|
|
@ -391,6 +299,21 @@ struct VMDirectory: Prunable {
|
||||||
try sizeBytes() / 1000 / 1000 / 1000
|
try sizeBytes() / 1000 / 1000 / 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diskSizeBytes() throws -> Int {
|
||||||
|
let vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
return switch vmConfig.diskFormat {
|
||||||
|
case .raw:
|
||||||
|
try sizeBytes()
|
||||||
|
case .asif:
|
||||||
|
try Diskutil.imageInfo(diskURL).totalBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func diskSizeGB() throws -> Int {
|
||||||
|
try diskSizeBytes() / 1000 / 1000 / 1000
|
||||||
|
}
|
||||||
|
|
||||||
func markExplicitlyPulled() {
|
func markExplicitlyPulled() {
|
||||||
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DiskutilTests: XCTestCase {
|
||||||
|
func testDiskutilInfo() throws {
|
||||||
|
// Create a temporary directory
|
||||||
|
let tempDirURL = FileManager.default.temporaryDirectory.appendingPathComponent("tart-diskutil-tests-\(UUID().uuidString)")
|
||||||
|
try? FileManager.default.createDirectory(at: tempDirURL, withIntermediateDirectories: true)
|
||||||
|
addTeardownBlock {
|
||||||
|
try? FileManager.default.removeItem(at: tempDirURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a 123 GB ASIF disk
|
||||||
|
let diskURL = tempDirURL.appendingPathComponent("disk.asif")
|
||||||
|
try Diskutil.imageCreate(diskURL: diskURL, sizeGB: 123)
|
||||||
|
|
||||||
|
// Retrieve its information and ensure that it does indeed take 123 GB
|
||||||
|
let info = try Diskutil.imageInfo(diskURL)
|
||||||
|
XCTAssertEqual(123 * 1000 * 1000 * 1000, try info.totalBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue