Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion libs/lume/src/Commands/Clone.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ struct Clone: AsyncParsableCommand {
@Option(name: .customLong("dest-storage"), help: "Destination VM storage location")
var destStorage: String?

@Flag(help: "Compact the disk image by removing unused space (zeroed blocks). Experimental.")
var compact: Bool = false

@Option(help: "Expand the disk size by the specified amount (e.g., 5GB, 1024MB)")
var expandBy: String?

init() {}

@MainActor
Expand All @@ -30,7 +36,9 @@ struct Clone: AsyncParsableCommand {
name: name,
newName: newName,
sourceLocation: sourceStorage,
destLocation: destStorage
destLocation: destStorage,
compact: compact,
expandBy: expandBy
)
}
}
55 changes: 55 additions & 0 deletions libs/lume/src/Commands/Resize.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import ArgumentParser
import Foundation

struct Resize: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Resize a virtual machine's disk image"
)

@Argument(help: "Name of the virtual machine to resize", completion: .custom(completeVMName))
var name: String

@Flag(help: "Compact the disk image by removing unused space (zeroed blocks)")
var compact: Bool = false

@Option(help: "Set absolute disk size (e.g., 50GB, 100GB)")
var size: String?

@Option(help: "Expand the disk size by the specified amount (e.g., 10GB, 5GB)")
var expandBy: String?

@Option(name: .customLong("storage"), help: "VM storage location to use or direct path to VM location")
var storage: String?

@Flag(name: .long, help: "Force resize without confirmation")
var force = false

init() {}

@MainActor
func run() async throws {
// Validate that at least one resize option is provided
guard compact || size != nil || expandBy != nil else {
throw ValidationError("Must specify at least one resize option: --compact, --size, or --expand-by")
}

// Validate that only one resize method is used at a time
let optionsCount = [compact, size != nil, expandBy != nil].filter { $0 }.count
guard optionsCount == 1 else {
throw ValidationError("Cannot use multiple resize options simultaneously. Choose one: --compact, --size, or --expand-by")
}

// Record telemetry
TelemetryClient.shared.record(event: TelemetryEvent.resize)

let vmController = LumeController()
try await vmController.resize(
name: name,
compact: compact,
size: size,
expandBy: expandBy,
storage: storage,
force: force
)
}
}
53 changes: 53 additions & 0 deletions libs/lume/src/FileSystem/Home.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,59 @@ final class Home {
}
}

/// Copies a VM directory manually to allow for disk modifications
/// - Parameters:
/// - sourceName: Name of the source VM
/// - destName: Name for the destination VM
/// - sourceLocation: Optional name of the source location
/// - destLocation: Optional name of the destination location
/// - compact: Whether to simple-compact the disk (skip zeros)
/// - Throws: HomeError or VMDirectoryError
func copyVMDirectoryManual(
from sourceName: String,
to destName: String,
sourceLocation: String? = nil,
destLocation: String? = nil,
compact: Bool = false
) throws {
let sourceDir = try getVMDirectory(sourceName, storage: sourceLocation)
let destDir = try getVMDirectory(destName, storage: destLocation)

// Check if destination directory exists at all
if destDir.exists() {
throw HomeError.directoryAlreadyExists(path: destDir.dir.path)
}

// Create destination directory
try createDirectory(at: destDir.dir.url)

// Copy auxiliary files (config, nvram, provisioning marker if exists)
// We skip 'sessions.json' as cloning shouldn't copy running session info
let filesToCopy = [
sourceDir.configPath,
sourceDir.nvramPath,
sourceDir.provisioningPath
]

for srcPath in filesToCopy {
if srcPath.exists() {
let dstPath = destDir.dir.file(srcPath.url.lastPathComponent)
try fileManager.copyItem(at: srcPath.url, to: dstPath.url)
}
}

// Handle Disk Copy
if compact {
Logger.info("Compacting disk image during clone...", metadata: ["source": sourceName])
try sourceDir.compactCopyDisk(to: destDir.diskPath)
} else {
// Standard copy if not compacting (but manual flow)
if sourceDir.diskPath.exists() {
try fileManager.copyItem(at: sourceDir.diskPath.url, to: destDir.diskPath.url)
}
}
}

// MARK: - Location Management

/// Adds a new VM location
Expand Down
123 changes: 123 additions & 0 deletions libs/lume/src/FileSystem/VMDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,74 @@ extension VMDirectory {
} catch {
}
}

/// Compacts and copies the VM's disk to a new location
/// - Parameter destination: The destination path for the compacted disk
/// - Throws: VMDirectoryError if the operation fails
func compactCopyDisk(to destination: Path) throws {
// Ensure source exists
guard diskPath.exists() else {
throw VMDirectoryError.diskOperationFailed("Source disk does not exist")
}

// Open source for reading
guard let sourceHandle = try? FileHandle(forReadingFrom: diskPath.url) else {
throw VMDirectoryError.diskOperationFailed("Could not open source disk for reading")
}
defer { try? sourceHandle.close() }

// Create destination file
if !FileManager.default.createFile(atPath: destination.path, contents: nil) {
throw VMDirectoryError.fileCreationFailed(destination.path)
}

// Open destination for writing
guard let destHandle = try? FileHandle(forWritingTo: destination.url) else {
throw VMDirectoryError.diskOperationFailed("Could not open destination disk for writing")
}
defer { try? destHandle.close() }

do {
// Get source size
let fileSize = try diskPath.url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0

// Set destination size to match source (but sparsely)
try destHandle.truncate(atOffset: UInt64(fileSize))

// Read and copy in chunks, skipping zero blocks
let chunkSize = 4 * 1024 * 1024 // 4MB chunks
var offset: UInt64 = 0
let total = UInt64(fileSize)

while offset < total {
// Seek to current offset in source
try sourceHandle.seek(toOffset: offset)

// Read next chunk
// Note: Using read(upToCount:) as this project targets macOS 14+
let data = try sourceHandle.read(upToCount: chunkSize) ?? Data()

if data.isEmpty {
break // EOF
}

// Check if data contains any non-zero bytes
// This is a simple optimization to avoid writing blocks of zeros
// File system sparse support will handle the unwritten areas
if data.contains(where: { $0 != 0 }) {
try destHandle.seek(toOffset: offset)
try destHandle.write(contentsOf: data)
}

offset += UInt64(data.count)
}

Logger.info("Compacted disk copy completed", metadata: ["originalSize": "\(fileSize)"])

} catch {
throw VMDirectoryError.diskOperationFailed(error.localizedDescription)
}
}
}

// MARK: - Configuration Management
Expand Down Expand Up @@ -333,4 +401,59 @@ extension VMDirectory {
sharedDirectories: nil
)
}

// MARK: - Disk Resize Operations

/// Compacts the VM's disk in-place by removing unused space
/// Creates a temporary sparse copy and replaces the original
/// - Throws: VMDirectoryError if the operation fails
func compactDisk() async throws {
let tempPath = dir.file("disk0.tmp.img")

Logger.info("Starting disk compaction", metadata: ["disk": diskPath.path])

// Create sparse copy
try compactCopyDisk(to: tempPath)

// Replace original with compacted version
try FileManager.default.removeItem(at: diskPath.url)
try FileManager.default.moveItem(at: tempPath.url, to: diskPath.url)

Logger.info("Disk compaction complete", metadata: ["disk": diskPath.path])
}

/// Resizes the VM's disk to the specified size using hdiutil
/// - Parameter newSize: The new size in bytes
/// - Throws: VMDirectoryError if the operation fails
func resizeDisk(_ newSize: UInt64) throws {
Logger.info("Resizing disk", metadata: [
"disk": diskPath.path,
"newSize": "\\(newSize)"
])

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/hdiutil")
process.arguments = [
"resize",
"-size", "\\(newSize)",
"-imageonly",
diskPath.path
]

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe

try process.run()
process.waitUntilExit()

guard process.terminationStatus == 0 else {
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? "Unknown error"
throw VMDirectoryError.diskOperationFailed("hdiutil resize failed: \\(output)")
}

Logger.info("Disk resize complete", metadata: ["disk": diskPath.path])
}
}

Loading