mirror of https://github.com/cirruslabs/tart.git
Introduce "tart import" and "tart export" commands (#386)
* Introduce "tart import" and "tart export" commands
* Use AppleArchive instead of ZIP and simply {ar,un}chive the VM dir
* Fix formatting
* Link to Apple's docs
* Print "importing..." and "exporting..." lines
This commit is contained in:
parent
9c48d66674
commit
41af674a88
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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("<name> 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,8 @@ struct Root: AsyncParsableCommand {
|
|||
IP.self,
|
||||
Pull.self,
|
||||
Push.self,
|
||||
Import.self,
|
||||
Export.self,
|
||||
Prune.self,
|
||||
Rename.self,
|
||||
Stop.self,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,4 +62,8 @@ class VMStorageLocal {
|
|||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func hasVMsWithMACAddress(macAddress: String) throws -> Bool {
|
||||
try list().contains { try $1.macAddress() == macAddress }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue