From 7dcebf9c04bc568d5431ce462d9e3fb37416992f Mon Sep 17 00:00:00 2001 From: Fedor Korotkov Date: Mon, 19 Feb 2024 15:51:25 -0500 Subject: [PATCH] Allow to override VirtioFS tag for shared directories (#733) --- Sources/tart/Commands/Run.swift | 129 ++++++++++++++-------- Tests/TartTests/DirecotryShareTests.swift | 48 +++++++- 2 files changed, 127 insertions(+), 50 deletions(-) diff --git a/Sources/tart/Commands/Run.swift b/Sources/tart/Commands/Run.swift index a8499e2..61f0236 100644 --- a/Sources/tart/Commands/Run.swift +++ b/Sources/tart/Commands/Run.swift @@ -91,18 +91,19 @@ struct Run: AsyncParsableCommand { #endif var rosettaTag: String? - @Option(help: ArgumentHelp(""" - Additional directory shares with an optional read-only specifier\n(e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\") - """, discussion: """ - Requires host to be macOS 13.0 (Ventura) or newer. - A shared directory is automatically mounted to "/Volumes/My Shared Files" directory on macOS, - while on Linux you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". - For macOS guests, they must be running macOS 13.0 (Ventura) or newer. + @Option(help: ArgumentHelp("Additional directory shares with an optional read-only and mount tag options (e.g. --dir=\"~/src/build\" or --dir=\"~/src/sources:ro\")", discussion: """ + Requires host to be macOS 13.0 (Ventura) or newer. macOS guests must be running macOS 13.0 (Ventura) or newer too. - In case of passing multiple directories it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\" - These names will be used as directory names under the mounting point inside guests. For the example above it will be - "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively. - """, valueName: "[name:]path[:ro]")) + Options are comma-separated and are as follows: + + * ro — mount this directory share in read-only mode instead of the default read-write (e.g. --dir=\"~/src/sources:ro\") + + * tag= — by default, the \"com.apple.virtio-fs.automount\" mount tag is used for all directory shares. On macOS, this causes the directories to be automatically mounted to "/Volumes/My Shared Files" directory. On Linux, you have to do it manually: "mount -t virtiofs com.apple.virtio-fs.automount /mount/point". + + Mount tag can be overridden by appending tag property to the directory share (e.g. --dir=\"~/src/build:tag=build\" or --dir=\"~/src/build:ro,tag=build\"). Then it can be mounted via "mount_virtiofs build ~/build" inside guest macOS and "mount -t virtiofs build ~/build" inside guest Linux. + + In case of passing multiple directories per mount tag it is required to prefix them with names e.g. --dir=\"build:~/src/build\" --dir=\"sources:~/src/sources:ro\". These names will be used as directory names under the mounting point inside guests. For the example above it will be "/Volumes/My Shared Files/build" and "/Volumes/My Shared Files/sources" respectively. + """, valueName: "[name:]path[:options]")) var dir: [String] = [] @Option(help: ArgumentHelp(""" @@ -450,33 +451,35 @@ struct Run: AsyncParsableCommand { throw UnsupportedOSError("directory sharing", "is") } - var directoryShares: [DirectoryShare] = [] + var allDirectoryShares: [DirectoryShare] = [] - var allNamedShares = true for rawDir in dir { - let directoryShare = try DirectoryShare(parseFrom: rawDir) - if (directoryShare.name == nil) { - allNamedShares = false + allDirectoryShares.append(try DirectoryShare(parseFrom: rawDir)) + } + + return try Dictionary(grouping: allDirectoryShares, by: {$0.mountTag}).map { mountTag, directoryShares in + let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: mountTag) + + var allNamedShares = true + for directoryShare in directoryShares { + if directoryShare.name == nil { + allNamedShares = false + } } - directoryShares.append(directoryShare) + if directoryShares.count == 1 { + let directoryShare = directoryShares.first! + let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration()) + sharingDevice.share = singleDirectoryShare + } else if !allNamedShares { + throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") + } else { + var directories: [String : VZSharedDirectory] = Dictionary() + try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() } + sharingDevice.share = VZMultipleDirectoryShare(directories: directories) + } + + return sharingDevice } - - - let automountTag = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag - let sharingDevice = VZVirtioFileSystemDeviceConfiguration(tag: automountTag) - if allNamedShares { - var directories: [String : VZSharedDirectory] = Dictionary() - try directoryShares.forEach { directories[$0.name!] = try $0.createConfiguration() } - sharingDevice.share = VZMultipleDirectoryShare(directories: directories) - } else if dir.count > 1 { - throw ValidationError("invalid --dir syntax: for multiple directory shares each one of them should be named") - } else if dir.count == 1 { - let directoryShare = directoryShares.first! - let singleDirectoryShare = VZSingleDirectoryShare(directory: try directoryShare.createConfiguration()) - sharingDevice.share = singleDirectoryShare - } - - return [sharingDevice] } private func rosettaDirectoryShare() throws -> [VZDirectorySharingDeviceConfiguration] { @@ -652,30 +655,62 @@ struct DirectoryShare { let name: String? let path: URL let readOnly: Bool + let mountTag: String init(parseFrom: String) throws { - let readOnlySuffix = ":ro" - readOnly = parseFrom.hasSuffix(readOnlySuffix) - let maybeNameAndURL = readOnly ? String(parseFrom.dropLast(readOnlySuffix.count)) : parseFrom + var parseFrom = parseFrom + + // Consume options + (self.readOnly, self.mountTag, parseFrom) = Self.parseOptions(parseFrom) + + // Special case for URLs + if parseFrom.hasPrefix("http:") || parseFrom.hasPrefix("https:") { + self.name = nil + self.path = URL(string: parseFrom)! - if maybeNameAndURL.starts(with: "https://") || maybeNameAndURL.starts(with: "http://") { - // just a URL - name = nil - path = URL(string: maybeNameAndURL)! return } - let splits = maybeNameAndURL.split(separator: ":", maxSplits: 1) + let arguments = parseFrom.split(separator: ":", maxSplits: 1) - if splits.count == 2 { - name = String(splits[0]) - path = String(splits[1]).toRemoteOrLocalURL() + if arguments.count == 2 { + self.name = String(arguments[0]) + self.path = String(arguments[1]).toRemoteOrLocalURL() } else { - name = nil - path = String(splits[0]).toRemoteOrLocalURL() + self.name = nil + self.path = String(arguments[0]).toRemoteOrLocalURL() } } + static func parseOptions(_ parseFrom: String) -> (Bool, String, String) { + var arguments = parseFrom.split(separator: ":") + let options = arguments.last!.split(separator: ",") + + var readOnly: Bool = false + var mountTag: String = VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag + + var found: Bool = false + + for option in options { + switch true { + case option == "ro": + readOnly = true + found = true + case option.hasPrefix("tag="): + mountTag = String(option.dropFirst(4)) + found = true + default: + continue + } + } + + if found { + arguments.removeLast() + } + + return (readOnly, mountTag, arguments.joined(separator: ":")) + } + func createConfiguration() throws -> VZSharedDirectory { if (path.isFileURL) { return VZSharedDirectory(url: path, readOnly: readOnly) diff --git a/Tests/TartTests/DirecotryShareTests.swift b/Tests/TartTests/DirecotryShareTests.swift index 6c28d7f..062da00 100644 --- a/Tests/TartTests/DirecotryShareTests.swift +++ b/Tests/TartTests/DirecotryShareTests.swift @@ -1,6 +1,8 @@ import XCTest @testable import tart +import Virtualization + final class DirectoryShareTests: XCTestCase { func testNamedParsing() throws { let share = try DirectoryShare(parseFrom: "build:/Users/admin/build") @@ -8,25 +10,65 @@ final class DirectoryShareTests: XCTestCase { XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) XCTAssertFalse(share.readOnly) } - + func testNamedReadOnlyParsing() throws { let share = try DirectoryShare(parseFrom: "build:/Users/admin/build:ro") XCTAssertEqual(share.name, "build") XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) XCTAssertTrue(share.readOnly) } - + func testOptionalNameParsing() throws { let share = try DirectoryShare(parseFrom: "/Users/admin/build") XCTAssertNil(share.name) XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) XCTAssertFalse(share.readOnly) } - + func testOptionalNameReadOnlyParsing() throws { let share = try DirectoryShare(parseFrom: "/Users/admin/build:ro") XCTAssertNil(share.name) XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) XCTAssertTrue(share.readOnly) } + + func testMountTagParsing() throws { + let share = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar") + XCTAssertNil(share.name) + XCTAssertEqual(share.path, URL(filePath: "/Users/admin/build")) + XCTAssertFalse(share.readOnly) + XCTAssertEqual(share.mountTag, "foo-bar") + + let roShare = try DirectoryShare(parseFrom: "/Users/admin/build:ro,tag=foo-bar") + XCTAssertNil(roShare.name) + XCTAssertEqual(roShare.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(roShare.readOnly) + XCTAssertEqual(roShare.mountTag, "foo-bar") + + let inverseRoShare = try DirectoryShare(parseFrom: "/Users/admin/build:tag=foo-bar,ro") + XCTAssertNil(inverseRoShare.name) + XCTAssertEqual(inverseRoShare.path, URL(filePath: "/Users/admin/build")) + XCTAssertTrue(inverseRoShare.readOnly) + XCTAssertEqual(inverseRoShare.mountTag, "foo-bar") + } + + func testURL() throws { + let archiveWithoutNameOrOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz") + XCTAssertNil(archiveWithoutNameOrOptions.name) + XCTAssertEqual(archiveWithoutNameOrOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + XCTAssertFalse(archiveWithoutNameOrOptions.readOnly) + XCTAssertEqual(archiveWithoutNameOrOptions.mountTag, VZVirtioFileSystemDeviceConfiguration.macOSGuestAutomountTag) + + let archiveWithOptions = try DirectoryShare(parseFrom: "https://example.com/archive.tar.gz:ro,tag=sometag") + XCTAssertNil(archiveWithOptions.name) + XCTAssertEqual(archiveWithOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + XCTAssertTrue(archiveWithOptions.readOnly) + XCTAssertEqual(archiveWithOptions.mountTag, "sometag") + + let archiveWithNameAndOptions = try DirectoryShare(parseFrom: "somename:https://example.com/archive.tar.gz:ro,tag=sometag") + XCTAssertEqual(archiveWithNameAndOptions.name, "somename") + XCTAssertEqual(archiveWithNameAndOptions.path, URL(string: "https://example.com/archive.tar.gz")!) + XCTAssertTrue(archiveWithNameAndOptions.readOnly) + XCTAssertEqual(archiveWithNameAndOptions.mountTag, "sometag") + } }