diff --git a/Sources/tart/Commands/Clone.swift b/Sources/tart/Commands/Clone.swift index 1b6d66c..7bf28c9 100644 --- a/Sources/tart/Commands/Clone.swift +++ b/Sources/tart/Commands/Clone.swift @@ -39,6 +39,7 @@ struct Clone: AsyncParsableCommand { try tmpVMDirLock.lock() try await withTaskCancellationHandler(operation: { + // Acquire a global lock let lock = try FileLock(lockURL: Config().tartHomeDir) try lock.lock() @@ -52,15 +53,3 @@ struct Clone: AsyncParsableCommand { }) } } - -fileprivate extension VMDirectory { - func macAddress() throws -> String { - try VMConfig(fromURL: configURL).macAddress.string - } -} - -fileprivate extension VMStorageLocal { - func hasVMsWithMACAddress(macAddress: String) throws -> Bool { - try list().contains { try $1.macAddress() == macAddress } - } -} diff --git a/Sources/tart/Commands/Export.swift b/Sources/tart/Commands/Export.swift new file mode 100644 index 0000000..7fc3c70 --- /dev/null +++ b/Sources/tart/Commands/Export.swift @@ -0,0 +1,16 @@ +import ArgumentParser + +struct Export: AsyncParsableCommand { + static var configuration = CommandConfiguration(abstract: "Export VM to a file") + + @Argument(help: "Source VM name.") + var name: String + + @Argument(help: "Path to the destination file.") + var path: String + + func run() async throws { + print("exporting...") + try VMStorageHelper.open(name).exportToArchive(path: path) + } +} diff --git a/Sources/tart/Commands/Import.swift b/Sources/tart/Commands/Import.swift new file mode 100644 index 0000000..465334b --- /dev/null +++ b/Sources/tart/Commands/Import.swift @@ -0,0 +1,51 @@ +import ArgumentParser +import Foundation + +struct Import: AsyncParsableCommand { + static var configuration = CommandConfiguration(abstract: "Import VM from a file") + + @Argument(help: "Path to a file created with \"tart export\".") + var path: String + + @Argument(help: "Destination VM name.") + var name: String + + func validate() throws { + if name.contains("/") { + throw ValidationError(" should be a local name") + } + } + + func run() async throws { + let localStorage = VMStorageLocal() + + // Create a temporary VM directory to which we will load the export file + let tmpVMDir = try VMDirectory.temporary() + + // Lock the temporary VM directory to prevent it's garbage collection + // while we're running + let tmpVMDirLock = try FileLock(lockURL: tmpVMDir.baseURL) + try tmpVMDirLock.lock() + + // Populate the temporary VM directory with the export file contents + print("importing...") + try tmpVMDir.importFromArchive(path: path) + + try await withTaskCancellationHandler(operation: { + // Acquire a global lock + let lock = try FileLock(lockURL: Config().tartHomeDir) + try lock.lock() + + // Re-generate the VM's MAC address importing it will result in address collision + if try localStorage.hasVMsWithMACAddress(macAddress: tmpVMDir.macAddress()) { + try tmpVMDir.regenerateMACAddress() + } + + try localStorage.move(name, from: tmpVMDir) + + try lock.unlock() + }, onCancel: { + try? FileManager.default.removeItem(at: tmpVMDir.baseURL) + }) + } +} diff --git a/Sources/tart/Root.swift b/Sources/tart/Root.swift index 29c4948..5d70c07 100644 --- a/Sources/tart/Root.swift +++ b/Sources/tart/Root.swift @@ -27,6 +27,8 @@ struct Root: AsyncParsableCommand { IP.self, Pull.self, Push.self, + Import.self, + Export.self, Prune.self, Rename.self, Stop.self, diff --git a/Sources/tart/VMDirectory+Archive.swift b/Sources/tart/VMDirectory+Archive.swift new file mode 100644 index 0000000..8086c6c --- /dev/null +++ b/Sources/tart/VMDirectory+Archive.swift @@ -0,0 +1,95 @@ +import System +import AppleArchive + +fileprivate let permissions = FilePermissions(rawValue: 0o644) + +// Compresses VMDirectory using Apple's proprietary archive format[1] and LZFSE compression, +// which is recommended on Apple platforms[2]. +// +// [1]: https://developer.apple.com/documentation/accelerate/compressing_file_system_directories +// [2]: https://developer.apple.com/documentation/compression/algorithm/lzfse +extension VMDirectory { + func exportToArchive(path: String) throws { + guard let fileStream = ArchiveByteStream.fileStream( + path: FilePath(path), + mode: .writeOnly, + options: [.create, .truncate], + permissions: permissions + ) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ExportFailed("ArchiveByteStream.fileStream() failed: \(details)") + } + defer { + try? fileStream.close() + } + + guard let compressionStream = ArchiveByteStream.compressionStream( + using: .lzfse, + writingTo: fileStream + ) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ExportFailed("ArchiveByteStream.compressionStream() failed: \(details)") + } + defer { + try? compressionStream.close() + } + + guard let encodeStream = ArchiveStream.encodeStream(writingTo: compressionStream) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ExportFailed("ArchiveStream.encodeStream() failed: \(details)") + } + defer { + try? encodeStream.close() + } + + guard let keySet = ArchiveHeader.FieldKeySet("TYP,PAT,LNK,DEV,DAT,UID,GID,MOD,FLG,MTM,BTM,CTM") else { + return + } + + try encodeStream.writeDirectoryContents(archiveFrom: FilePath(baseURL.path), keySet: keySet) + } + + func importFromArchive(path: String) throws { + guard let fileStream = ArchiveByteStream.fileStream(path: FilePath(path), mode: .readOnly, options: [], + permissions: permissions) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ImportFailed("ArchiveByteStream.fileStream() failed: \(details)") + } + defer { + try? fileStream.close() + } + + guard let decompressionStream = ArchiveByteStream.decompressionStream(readingFrom: fileStream) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ImportFailed("ArchiveByteStream.decompressionStream() failed: \(details)") + } + defer { + try? decompressionStream.close() + } + + guard let decodeStream = ArchiveStream.decodeStream(readingFrom: decompressionStream) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ImportFailed("ArchiveStream.decodeStream() failed: \(details)") + } + defer { + try? decodeStream.close() + } + + guard let extractStream = ArchiveStream.extractStream(extractingTo: FilePath(baseURL.path)) else { + let details = Errno(rawValue: CInt(errno)) + + throw RuntimeError.ImportFailed("ArchiveStream.extractStream() failed: \(details)") + } + defer { + try? extractStream.close() + } + + _ = try ArchiveStream.process(readingFrom: decodeStream, writingTo: extractStream) + } +} diff --git a/Sources/tart/VMDirectory.swift b/Sources/tart/VMDirectory.swift index 617f72d..707125b 100644 --- a/Sources/tart/VMDirectory.swift +++ b/Sources/tart/VMDirectory.swift @@ -68,11 +68,21 @@ struct VMDirectory: Prunable { try FileManager.default.copyItem(at: diskURL, to: to.diskURL) // Re-generate MAC address - var newVMConfig = try VMConfig(fromURL: to.configURL) if generateMAC { - newVMConfig.macAddress = VZMACAddress.randomLocallyAdministered() + try to.regenerateMACAddress() } - try newVMConfig.save(toURL: to.configURL) + } + + func macAddress() throws -> String { + try VMConfig(fromURL: configURL).macAddress.string + } + + func regenerateMACAddress() throws { + var vmConfig = try VMConfig(fromURL: configURL) + + vmConfig.macAddress = VZMACAddress.randomLocallyAdministered() + + try vmConfig.save(toURL: configURL) } func resizeDisk(_ sizeGB: UInt16) throws { diff --git a/Sources/tart/VMStorageHelper.swift b/Sources/tart/VMStorageHelper.swift index 2dde162..5afec1a 100644 --- a/Sources/tart/VMStorageHelper.swift +++ b/Sources/tart/VMStorageHelper.swift @@ -53,6 +53,8 @@ enum RuntimeError : Error { case VMTerminationFailed(_ message: String) case InvalidCredentials(_ message: String) case VMDirectoryAlreadyInitialized(_ message: String) + case ExportFailed(_ message: String) + case ImportFailed(_ message: String) } protocol HasExitCode { @@ -86,6 +88,10 @@ extension RuntimeError : CustomStringConvertible { return message case .VMDirectoryAlreadyInitialized(let message): return message + case .ExportFailed(let message): + return "VM export failed: \(message)" + case .ImportFailed(let message): + return "VM import failed: \(message)" } } } diff --git a/Sources/tart/VMStorageLocal.swift b/Sources/tart/VMStorageLocal.swift index 811a33b..98e588f 100644 --- a/Sources/tart/VMStorageLocal.swift +++ b/Sources/tart/VMStorageLocal.swift @@ -62,4 +62,8 @@ class VMStorageLocal { throw error } } + + func hasVMsWithMACAddress(macAddress: String) throws -> Bool { + try list().contains { try $1.macAddress() == macAddress } + } }