mirror of
https://github.com/cirruslabs/tart.git
synced 2026-09-29 18:51:19 +02:00
feat: Add disk image format selection with ASIF support (#1094)
* feat: Add disk image format selection with ASIF support * fixed goreleaser-pro * Fix ASIF disk format compatibility issues - Use .uncached caching mode for ASIF disks to avoid Virtualization framework compatibility issues - Improve caching mode selection logic for better maintainability - Fix compiler warning by changing var to let for attachment variable This resolves VM startup failures when using ASIF disk format by ensuring proper disk attachment configuration. * Update goreleaser installation to use tap-specific formula Change from 'brew install --cask goreleaser-pro' to 'brew install --cask goreleaser/tap/goreleaser-pro' for proper installation from the official goreleaser tap. * Remove VS Code configuration and add to gitignore - Remove .vscode/launch.json from repository - Add .vscode/ to .gitignore to prevent VS Code settings from being tracked * Implement ASIF disk resize using diskutil - Add support for resizing ASIF disk images using diskutil image resize - Detect disk format from VM config and route to appropriate resize method - Use diskutil image info to get current ASIF disk size and validate resize - Remove restriction that prevented ASIF disk resizing in Set command - Add FailedToResizeDisk error case for proper error handling - Maintain backward compatibility with raw disk resizing - Add comprehensive size validation to prevent data loss * Update Sources/tart/Commands/Create.swift Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com> * Update Sources/tart/DiskImageFormat.swift Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com> * Update Sources/tart/DiskImageFormat.swift Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com> * Fix test naming and remove redundant test cases - Rename testFormatArgument to testCaseInsensitivity for clarity - Remove redundant 'raw' and 'invalid' test cases already covered in testFormatFromString - Remove testFormatDescriptions test as it's not very useful Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152093510 * Remove canCreate property and simplify DiskImageFormat - Remove canCreate property since it's the same as isSupported - Remove description property entirely as it's not used - Fix displayName for RAW format (remove UDIF reference) - Remove checkDiskutilASIFSupport helper function Addresses review comments: - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450 - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152115610 - https://github.com/cirruslabs/tart/pull/1094#discussion_r2152124330 * Update Create command validation and help text - Simplify ArgumentParser help text to let it show possible values automatically - Remove canCreate validation since property was removed - Simplify error message for unsupported disk formats Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152113480 * Add disk format validation to Run command - Add validation to ensure ASIF disk format is supported on current system - Check disk format compatibility before attempting to run VM Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152109450 * Use proper namespaced constant for OCI label - Add diskFormatLabelAnnotation constant in Manifest.swift - Use org.cirruslabs.tart.disk.format namespace for consistency - Use variable shadowing instead of new variable name for labels Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152163515 * Remove special ASIF caching mode - Remove .uncached caching mode for ASIF disks - Use default caching logic for all disk formats - Testing shows .cached mode works fine on macOS 26.0 Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152133589 * Improve code structure in VMDirectory - Use guard let instead of nested if let for better readability - Reduce nesting in resizeASIFDisk function - Improve error handling flow Addresses review comment: https://github.com/cirruslabs/tart/pull/1094#discussion_r2152141916 * diskFormatLabel * reverted caching mode * Use PropertyListDecoder --------- Co-authored-by: Nikolay Edigaryev <edigaryev@gmail.com>
This commit is contained in:
co-authored by
Nikolay Edigaryev
parent
5793935317
commit
3a6c5fb81d
@@ -8,6 +8,9 @@ tart.xcodeproj/
|
|||||||
# AppCode
|
# AppCode
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
# Swift
|
# Swift
|
||||||
.build/
|
.build/
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ struct Create: AsyncParsableCommand {
|
|||||||
@Option(help: ArgumentHelp("Disk size in GB"))
|
@Option(help: ArgumentHelp("Disk size in GB"))
|
||||||
var diskSize: UInt16 = 50
|
var diskSize: UInt16 = 50
|
||||||
|
|
||||||
|
@Option(help: ArgumentHelp("Disk image format", discussion: "ASIF format provides better performance but requires macOS 26 Tahoe or later"))
|
||||||
|
var diskFormat: DiskImageFormat = .raw
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
if fromIPSW == nil && !linux {
|
if fromIPSW == nil && !linux {
|
||||||
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
throw ValidationError("Please specify either a --from-ipsw or --linux option!")
|
||||||
@@ -28,6 +31,11 @@ struct Create: AsyncParsableCommand {
|
|||||||
throw ValidationError("Only Linux VMs are supported on Intel!")
|
throw ValidationError("Only Linux VMs are supported on Intel!")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// Validate disk format support
|
||||||
|
if !diskFormat.isSupported {
|
||||||
|
throw ValidationError("Disk format '\(diskFormat.rawValue)' is not supported on this system.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func run() async throws {
|
func run() async throws {
|
||||||
@@ -58,12 +66,12 @@ struct Create: AsyncParsableCommand {
|
|||||||
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
ipswURL = URL(fileURLWithPath: NSString(string: fromIPSW).expandingTildeInPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize)
|
_ = try await VM(vmDir: tmpVMDir, ipswURL: ipswURL, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if linux {
|
if linux {
|
||||||
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize)
|
_ = try await VM.linux(vmDir: tmpVMDir, diskSizeGB: diskSize, diskFormat: diskFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
try VMStorageLocal().move(name, from: tmpVMDir)
|
try VMStorageLocal().move(name, from: tmpVMDir)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ fileprivate struct VMInfo: Encodable {
|
|||||||
let CPU: Int
|
let CPU: Int
|
||||||
let Memory: UInt64
|
let Memory: UInt64
|
||||||
let Disk: Int
|
let Disk: Int
|
||||||
|
let DiskFormat: String
|
||||||
let Size: String
|
let Size: String
|
||||||
let Display: String
|
let Display: String
|
||||||
let Running: Bool
|
let Running: Bool
|
||||||
@@ -26,7 +27,7 @@ struct Get: AsyncParsableCommand {
|
|||||||
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
let memorySizeInMb = vmConfig.memorySize / 1024 / 1024
|
||||||
|
|
||||||
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
let info = VMInfo(OS: vmConfig.os, CPU: vmConfig.cpuCount, Memory: memorySizeInMb, Disk: try vmDir.sizeGB(), DiskFormat: vmConfig.diskFormat.rawValue, Size: String(format: "%.3f", Float(try vmDir.allocatedSizeBytes()) / 1000 / 1000 / 1000), Display: vmConfig.display.description, Running: try vmDir.running(), State: try vmDir.state().rawValue)
|
||||||
print(format.renderSingle(info))
|
print(format.renderSingle(info))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,6 +345,12 @@ struct Run: AsyncParsableCommand {
|
|||||||
let localStorage = VMStorageLocal()
|
let localStorage = VMStorageLocal()
|
||||||
let vmDir = try localStorage.open(name)
|
let vmDir = try localStorage.open(name)
|
||||||
|
|
||||||
|
// Validate disk format support
|
||||||
|
let vmConfig = try VMConfig(fromURL: vmDir.configURL)
|
||||||
|
if !vmConfig.diskFormat.isSupported {
|
||||||
|
throw ValidationError("Disk format '\(vmConfig.diskFormat.rawValue)' is not supported on this system.")
|
||||||
|
}
|
||||||
|
|
||||||
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
let storageLock = try FileLock(lockURL: Config().tartHomeDir)
|
||||||
try storageLock.lock()
|
try storageLock.lock()
|
||||||
// check if there is a running VM with the same MAC address
|
// check if there is a running VM with the same MAC address
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
import ArgumentParser
|
||||||
|
|
||||||
|
enum DiskImageFormat: String, CaseIterable, Codable {
|
||||||
|
case raw = "raw"
|
||||||
|
case asif = "asif"
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .raw:
|
||||||
|
return "RAW"
|
||||||
|
case .asif:
|
||||||
|
return "ASIF (Apple Sparse Image Format)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Check if the format is supported on the current system
|
||||||
|
var isSupported: Bool {
|
||||||
|
switch self {
|
||||||
|
case .raw:
|
||||||
|
return true
|
||||||
|
case .asif:
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DiskImageFormat: ExpressibleByArgument {
|
||||||
|
init?(argument: String) {
|
||||||
|
self.init(rawValue: argument.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
static var allValueStrings: [String] {
|
||||||
|
return allCases.map { $0.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ let nvramMediaType = "application/vnd.cirruslabs.tart.nvram.v1"
|
|||||||
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
let uncompressedDiskSizeAnnotation = "org.cirruslabs.tart.uncompressed-disk-size"
|
||||||
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
let uploadTimeAnnotation = "org.cirruslabs.tart.upload-time"
|
||||||
|
|
||||||
|
// Manifest labels
|
||||||
|
let diskFormatLabel = "org.cirruslabs.tart.disk.format"
|
||||||
|
|
||||||
// Layer annotations
|
// Layer annotations
|
||||||
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
let uncompressedSizeAnnotation = "org.cirruslabs.tart.uncompressed-size"
|
||||||
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
let uncompressedContentDigestAnnotation = "org.cirruslabs.tart.uncompressed-content-digest"
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
vmDir: VMDirectory,
|
vmDir: VMDirectory,
|
||||||
ipswURL: URL,
|
ipswURL: URL,
|
||||||
diskSizeGB: UInt16,
|
diskSizeGB: UInt16,
|
||||||
|
diskFormat: DiskImageFormat = .raw,
|
||||||
network: Network = NetworkShared(),
|
network: Network = NetworkShared(),
|
||||||
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
additionalStorageDevices: [VZStorageDeviceConfiguration] = [],
|
||||||
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
directorySharingDevices: [VZDirectorySharingDeviceConfiguration] = [],
|
||||||
@@ -175,14 +176,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
_ = try VZMacAuxiliaryStorage(creatingStorageAt: vmDir.nvramURL, hardwareModel: requirements.hardwareModel)
|
||||||
|
|
||||||
// Create disk
|
// Create disk
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||||
|
|
||||||
name = vmDir.name
|
name = vmDir.name
|
||||||
// Create config
|
// Create config
|
||||||
config = VMConfig(
|
config = VMConfig(
|
||||||
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
platform: Darwin(ecid: VZMacMachineIdentifier(), hardwareModel: requirements.hardwareModel),
|
||||||
cpuCountMin: requirements.minimumSupportedCPUCount,
|
cpuCountMin: requirements.minimumSupportedCPUCount,
|
||||||
memorySizeMin: requirements.minimumSupportedMemorySize
|
memorySizeMin: requirements.minimumSupportedMemorySize,
|
||||||
|
diskFormat: diskFormat
|
||||||
)
|
)
|
||||||
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
// allocate at least 4 CPUs because otherwise VMs are frequently freezing
|
||||||
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
try config.setCPU(cpuCount: max(4, requirements.minimumSupportedCPUCount))
|
||||||
@@ -224,15 +226,15 @@ class VM: NSObject, VZVirtualMachineDelegate, ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
@available(macOS 13, *)
|
@available(macOS 13, *)
|
||||||
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16) async throws -> VM {
|
static func linux(vmDir: VMDirectory, diskSizeGB: UInt16, diskFormat: DiskImageFormat = .raw) async throws -> VM {
|
||||||
// Create NVRAM
|
// Create NVRAM
|
||||||
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
_ = try VZEFIVariableStore(creatingVariableStoreAt: vmDir.nvramURL)
|
||||||
|
|
||||||
// Create disk
|
// Create disk
|
||||||
try vmDir.resizeDisk(diskSizeGB)
|
try vmDir.resizeDisk(diskSizeGB, format: diskFormat)
|
||||||
|
|
||||||
// Create config
|
// Create config
|
||||||
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024)
|
let config = VMConfig(platform: Linux(), cpuCountMin: 4, memorySizeMin: 4096 * 1024 * 1024, diskFormat: diskFormat)
|
||||||
try config.save(toURL: vmDir.configURL)
|
try config.save(toURL: vmDir.configURL)
|
||||||
|
|
||||||
return try VM(vmDir: vmDir)
|
return try VM(vmDir: vmDir)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ enum CodingKeys: String, CodingKey {
|
|||||||
case macAddress
|
case macAddress
|
||||||
case display
|
case display
|
||||||
case displayRefit
|
case displayRefit
|
||||||
|
case diskFormat
|
||||||
|
|
||||||
// macOS-specific keys
|
// macOS-specific keys
|
||||||
case ecid
|
case ecid
|
||||||
@@ -54,12 +55,14 @@ struct VMConfig: Codable {
|
|||||||
var macAddress: VZMACAddress
|
var macAddress: VZMACAddress
|
||||||
var display: VMDisplayConfig = VMDisplayConfig()
|
var display: VMDisplayConfig = VMDisplayConfig()
|
||||||
var displayRefit: Bool?
|
var displayRefit: Bool?
|
||||||
|
var diskFormat: DiskImageFormat = .raw
|
||||||
|
|
||||||
init(
|
init(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
cpuCountMin: Int,
|
cpuCountMin: Int,
|
||||||
memorySizeMin: UInt64,
|
memorySizeMin: UInt64,
|
||||||
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered()
|
macAddress: VZMACAddress = VZMACAddress.randomLocallyAdministered(),
|
||||||
|
diskFormat: DiskImageFormat = .raw
|
||||||
) {
|
) {
|
||||||
self.os = platform.os()
|
self.os = platform.os()
|
||||||
self.arch = CurrentArchitecture()
|
self.arch = CurrentArchitecture()
|
||||||
@@ -67,6 +70,7 @@ struct VMConfig: Codable {
|
|||||||
self.macAddress = macAddress
|
self.macAddress = macAddress
|
||||||
self.cpuCountMin = cpuCountMin
|
self.cpuCountMin = cpuCountMin
|
||||||
self.memorySizeMin = memorySizeMin
|
self.memorySizeMin = memorySizeMin
|
||||||
|
self.diskFormat = diskFormat
|
||||||
cpuCount = cpuCountMin
|
cpuCount = cpuCountMin
|
||||||
memorySize = memorySizeMin
|
memorySize = memorySizeMin
|
||||||
}
|
}
|
||||||
@@ -124,6 +128,8 @@ struct VMConfig: Codable {
|
|||||||
|
|
||||||
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
display = try container.decodeIfPresent(VMDisplayConfig.self, forKey: .display) ?? VMDisplayConfig()
|
||||||
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
displayRefit = try container.decodeIfPresent(Bool.self, forKey: .displayRefit)
|
||||||
|
let diskFormatString = try container.decodeIfPresent(String.self, forKey: .diskFormat) ?? "raw"
|
||||||
|
diskFormat = DiskImageFormat(rawValue: diskFormatString) ?? .raw
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -142,6 +148,7 @@ struct VMConfig: Codable {
|
|||||||
if let displayRefit = displayRefit {
|
if let displayRefit = displayRefit {
|
||||||
try container.encode(displayRefit, forKey: .displayRefit)
|
try container.encode(displayRefit, forKey: .displayRefit)
|
||||||
}
|
}
|
||||||
|
try container.encode(diskFormat.rawValue, forKey: .diskFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func setCPU(cpuCount: Int) throws {
|
mutating func setCPU(cpuCount: Int) throws {
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ extension VMDirectory {
|
|||||||
|
|
||||||
// Read VM's config and push it as blob
|
// Read VM's config and push it as blob
|
||||||
let config = try VMConfig(fromURL: configURL)
|
let config = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
// Add disk format label automatically
|
||||||
|
var labels = labels
|
||||||
|
labels[diskFormatLabel] = config.diskFormat.rawValue
|
||||||
let configJSON = try JSONEncoder().encode(config)
|
let configJSON = try JSONEncoder().encode(config)
|
||||||
defaultLogger.appendNewLine("pushing config...")
|
defaultLogger.appendNewLine("pushing config...")
|
||||||
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
let configDigest = try await registry.pushBlob(fromData: configJSON, chunkSizeMb: chunkSizeMb)
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ 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"
|
||||||
@@ -142,14 +161,34 @@ struct VMDirectory: Prunable {
|
|||||||
try vmConfig.save(toURL: configURL)
|
try vmConfig.save(toURL: configURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resizeDisk(_ sizeGB: UInt16) throws {
|
func resizeDisk(_ sizeGB: UInt16, format: DiskImageFormat = .raw) throws {
|
||||||
if !FileManager.default.fileExists(atPath: diskURL.path) {
|
let diskExists = FileManager.default.fileExists(atPath: diskURL.path)
|
||||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if diskExists {
|
||||||
|
// Existing disk - resize it
|
||||||
|
try resizeExistingDisk(sizeGB)
|
||||||
|
} else {
|
||||||
|
// New disk - create it with the specified format
|
||||||
|
try createDisk(sizeGB: sizeGB, format: format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeExistingDisk(_ sizeGB: UInt16) throws {
|
||||||
|
// Check if this is an ASIF disk by reading the VM config
|
||||||
|
let vmConfig = try VMConfig(fromURL: configURL)
|
||||||
|
|
||||||
|
if vmConfig.diskFormat == .asif {
|
||||||
|
try resizeASIFDisk(sizeGB)
|
||||||
|
} else {
|
||||||
|
try resizeRawDisk(sizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeRawDisk(_ sizeGB: UInt16) throws {
|
||||||
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
let currentDiskFileLength = try diskFileHandle.seekToEnd()
|
||||||
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
|
||||||
if desiredDiskFileLength < currentDiskFileLength {
|
if desiredDiskFileLength < currentDiskFileLength {
|
||||||
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
let currentLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(currentDiskFileLength))
|
||||||
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
let desiredLengthHuman = ByteCountFormatter().string(fromByteCount: Int64(desiredDiskFileLength))
|
||||||
@@ -161,6 +200,157 @@ struct VMDirectory: Prunable {
|
|||||||
try diskFileHandle.close()
|
try diskFileHandle.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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 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)
|
||||||
|
}
|
||||||
|
// 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 {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to get disk image info: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performASIFResize(_ sizeGB: UInt16) throws {
|
||||||
|
guard let diskutilURL = resolveBinaryPath("diskutil") else {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("diskutil not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = diskutilURL
|
||||||
|
process.arguments = [
|
||||||
|
"image", "resize",
|
||||||
|
"--size", "\(sizeGB)G",
|
||||||
|
diskURL.path
|
||||||
|
]
|
||||||
|
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = pipe
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
|
||||||
|
if process.terminationStatus != 0 {
|
||||||
|
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to resize ASIF disk image: \(output)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw RuntimeError.FailedToResizeDisk("Failed to execute diskutil resize: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createDisk(sizeGB: UInt16, format: DiskImageFormat) throws {
|
||||||
|
switch format {
|
||||||
|
case .raw:
|
||||||
|
try createRawDisk(sizeGB: sizeGB)
|
||||||
|
case .asif:
|
||||||
|
try createASIFDisk(sizeGB: sizeGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRawDisk(sizeGB: UInt16) throws {
|
||||||
|
// Create traditional raw disk image
|
||||||
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil, attributes: nil)
|
||||||
|
|
||||||
|
let diskFileHandle = try FileHandle.init(forWritingTo: diskURL)
|
||||||
|
let desiredDiskFileLength = UInt64(sizeGB) * 1000 * 1000 * 1000
|
||||||
|
try diskFileHandle.truncate(atOffset: desiredDiskFileLength)
|
||||||
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ enum RuntimeError : Error {
|
|||||||
case DiskAlreadyInUse(_ message: String)
|
case DiskAlreadyInUse(_ message: String)
|
||||||
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
case FailedToOpenBlockDevice(_ path: String, _ explanation: String)
|
||||||
case InvalidDiskSize(_ message: String)
|
case InvalidDiskSize(_ message: String)
|
||||||
|
case FailedToCreateDisk(_ message: String)
|
||||||
|
case FailedToResizeDisk(_ message: String)
|
||||||
case FailedToUpdateAccessDate(_ message: String)
|
case FailedToUpdateAccessDate(_ message: String)
|
||||||
case PIDLockFailed(_ message: String)
|
case PIDLockFailed(_ message: String)
|
||||||
case PIDLockMissing(_ message: String)
|
case PIDLockMissing(_ message: String)
|
||||||
@@ -109,6 +111,10 @@ extension RuntimeError : CustomStringConvertible {
|
|||||||
return "failed to open block device \(path): \(explanation)"
|
return "failed to open block device \(path): \(explanation)"
|
||||||
case .InvalidDiskSize(let message):
|
case .InvalidDiskSize(let message):
|
||||||
return message
|
return message
|
||||||
|
case .FailedToCreateDisk(let message):
|
||||||
|
return message
|
||||||
|
case .FailedToResizeDisk(let message):
|
||||||
|
return message
|
||||||
case .FailedToUpdateAccessDate(let message):
|
case .FailedToUpdateAccessDate(let message):
|
||||||
return message
|
return message
|
||||||
case .PIDLockFailed(let message):
|
case .PIDLockFailed(let message):
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import tart
|
||||||
|
|
||||||
|
final class DiskImageFormatTests: XCTestCase {
|
||||||
|
func testRawFormatIsAlwaysSupported() throws {
|
||||||
|
XCTAssertTrue(DiskImageFormat.raw.isSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testASIFFormatSupport() throws {
|
||||||
|
// ASIF should be supported on macOS 15+
|
||||||
|
if #available(macOS 15, *) {
|
||||||
|
XCTAssertTrue(DiskImageFormat.asif.isSupported)
|
||||||
|
} else {
|
||||||
|
XCTAssertFalse(DiskImageFormat.asif.isSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFormatFromString() throws {
|
||||||
|
XCTAssertEqual(DiskImageFormat(rawValue: "raw"), .raw)
|
||||||
|
XCTAssertEqual(DiskImageFormat(rawValue: "asif"), .asif)
|
||||||
|
XCTAssertNil(DiskImageFormat(rawValue: "invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaseInsensitivity() throws {
|
||||||
|
XCTAssertEqual(DiskImageFormat(argument: "ASIF"), .asif) // case insensitive
|
||||||
|
XCTAssertEqual(DiskImageFormat(argument: "Raw"), .raw) // case insensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAllValueStrings() throws {
|
||||||
|
let allValues = DiskImageFormat.allValueStrings
|
||||||
|
XCTAssertTrue(allValues.contains("raw"))
|
||||||
|
XCTAssertTrue(allValues.contains("asif"))
|
||||||
|
XCTAssertEqual(allValues.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVMConfigDiskFormatSerialization() throws {
|
||||||
|
// Test that VMConfig properly serializes and deserializes disk format
|
||||||
|
let config = VMConfig(
|
||||||
|
platform: Linux(),
|
||||||
|
cpuCountMin: 2,
|
||||||
|
memorySizeMin: 1024 * 1024 * 1024,
|
||||||
|
diskFormat: .asif
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.diskFormat, .asif)
|
||||||
|
|
||||||
|
// Test JSON encoding/decoding
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(config)
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let decodedConfig = try decoder.decode(VMConfig.self, from: data)
|
||||||
|
|
||||||
|
XCTAssertEqual(decodedConfig.diskFormat, .asif)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVMConfigDefaultDiskFormat() throws {
|
||||||
|
// Test that VMConfig defaults to raw format
|
||||||
|
let config = VMConfig(
|
||||||
|
platform: Linux(),
|
||||||
|
cpuCountMin: 2,
|
||||||
|
memorySizeMin: 1024 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.diskFormat, .raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user