diff --git a/.cirrus.yml b/.cirrus.yml index bb366c6..b4148f0 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -11,6 +11,8 @@ task: build_script: - swift build test_script: + # Add /usr/sbin to PATH, otherwise testDiskutilInfo() fails to locate "diskutil" + - export PATH=$PATH:/usr/sbin - swift test integration_test_script: - codesign --sign - --entitlements Resources/tart-dev.entitlements --force .build/debug/tart diff --git a/Sources/tart/Diskutil.swift b/Sources/tart/Diskutil.swift new file mode 100644 index 0000000..d93e05e --- /dev/null +++ b/Sources/tart/Diskutil.swift @@ -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 "" + } +} diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 36d224e..c1ca268 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -2,25 +2,6 @@ import Foundation import Virtualization 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 { enum State: String { case Running = "running" @@ -201,69 +182,28 @@ struct VMDirectory: Prunable { } 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 { - try infoProcess.run() - infoProcess.waitUntilExit() + let diskImageInfo = try Diskutil.imageInfo(diskURL) - let infoData = infoPipe.fileHandleForReading.readDataToEndOfFile() + let currentSizeBytes = try diskImageInfo.totalBytes() + let desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000 - if infoProcess.terminationStatus != 0 { - let output = String(data: infoData, encoding: .utf8) ?? "Unknown error" - throw RuntimeError.FailedToResizeDisk("Failed to get ASIF disk info: \(output)") - } + if desiredSizeBytes < currentSizeBytes { + let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes)) + let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes)) - // 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 desiredSizeBytes = UInt64(sizeGB) * 1000 * 1000 * 1000 - - if desiredSizeBytes < currentSizeBytes { - let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentSizeBytes)) - let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredSizeBytes)) - throw RuntimeError.InvalidDiskSize("new disk size of \(desiredLengthHuman) should be larger " + - "than the current disk size of \(currentLengthHuman)") - } else if desiredSizeBytes > currentSizeBytes { - // Resize the ASIF disk image using diskutil - try performASIFResize(sizeGB) - } + throw RuntimeError.InvalidDiskSize("New disk size of \(desiredLengthHuman) should be larger " + + "than the current disk size of \(currentLengthHuman)") + } else if desiredSizeBytes > currentSizeBytes { + // Resize the ASIF disk image using diskutil + try performASIFResize(sizeGB) + } else { // If sizes are equal, no action needed - } catch let error as RuntimeError { - throw error - } catch { - let outputString = String(data: infoData, encoding: .utf8) ?? "Unable to decode output" - throw RuntimeError.FailedToResizeDisk("Failed to parse disk image info: \(error). Output: \(outputString)") } + } catch let error as RuntimeError { + throw error } catch { - throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)") + throw RuntimeError.FailedToResizeDisk("\(error)") } } @@ -304,7 +244,7 @@ struct VMDirectory: Prunable { case .raw: try createRawDisk(sizeGB: sizeGB) case .asif: - try createASIFDisk(sizeGB: sizeGB) + try Diskutil.imageCreate(diskURL: diskURL, sizeGB: sizeGB) } } @@ -318,38 +258,6 @@ struct VMDirectory: Prunable { 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 { let lock = try lock() @@ -391,6 +299,21 @@ struct VMDirectory: Prunable { 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() { FileManager.default.createFile(atPath: explicitlyPulledMark.path, contents: nil) } diff --git a/Tests/TartTests/DiskutilTests.swift b/Tests/TartTests/DiskutilTests.swift new file mode 100644 index 0000000..c2e3ac8 --- /dev/null +++ b/Tests/TartTests/DiskutilTests.swift @@ -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()) + } +}