diff --git a/Sources/ContainerCommands/Machine/MachineCreate.swift b/Sources/ContainerCommands/Machine/MachineCreate.swift index 4cf2a6d76..3fe6d1556 100644 --- a/Sources/ContainerCommands/Machine/MachineCreate.swift +++ b/Sources/ContainerCommands/Machine/MachineCreate.swift @@ -65,6 +65,11 @@ extension Application { @Option(name: .long, help: "User's home directory mount option (ro, rw, none). Default: rw") public var homeMount: String? + @Option( + name: .long, + help: "Mount a host directory into the container machine (format: host:guest[:ro|rw]). Can be repeated.") + public var mount: [String] = [] + @Flag(name: .long, help: "Enable nested virtualization (requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y)") public var virtualization: Bool = false @@ -102,7 +107,8 @@ extension Application { "home-mount": homeMount, "virtualization": virtualization ? "true" : nil, "kernel": resolvedKernel?.string, - ].compactMapValues { $0 } + ].compactMapValues { $0 }, + mounts: mount.isEmpty ? nil : mount ) let id: String diff --git a/Sources/ContainerCommands/Machine/MachineInspect.swift b/Sources/ContainerCommands/Machine/MachineInspect.swift index 49053c7e6..326c3e0a6 100644 --- a/Sources/ContainerCommands/Machine/MachineInspect.swift +++ b/Sources/ContainerCommands/Machine/MachineInspect.swift @@ -64,6 +64,7 @@ private struct InspectOutput: Codable { let cpus: Int let memory: UInt64 let homeMount: MachineConfig.HomeMountOption + let mounts: [MachineConfig.Mount] let diskSize: UInt64? let ipAddress: String? @@ -79,6 +80,7 @@ private struct InspectOutput: Codable { self.cpus = snapshot.bootConfig.cpus self.memory = snapshot.bootConfig.memory.toUInt64(unit: .bytes) self.homeMount = snapshot.bootConfig.homeMount + self.mounts = snapshot.bootConfig.mounts self.diskSize = snapshot.diskSize self.ipAddress = snapshot.ipAddress } diff --git a/Sources/ContainerPersistence/MachineConfig.swift b/Sources/ContainerPersistence/MachineConfig.swift index 6c8161243..e0eb1fa07 100644 --- a/Sources/ContainerPersistence/MachineConfig.swift +++ b/Sources/ContainerPersistence/MachineConfig.swift @@ -46,6 +46,25 @@ public struct MachineConfig: Codable, Sendable { case none } + /// A user-specified host directory bind-mounted into the container machine. + /// + /// Stored as plain absolute paths so this type stays self-contained within + /// `ContainerPersistence`; it is lifted to a virtiofs share at boot time. + public struct Mount: Codable, Sendable, Equatable { + /// Absolute host directory path. + public let source: String + /// Absolute mount point inside the container machine. + public let destination: String + /// Whether the mount is read-only. `false` means read-write. + public let readOnly: Bool + + public init(source: String, destination: String, readOnly: Bool) { + self.source = source + self.destination = destination + self.readOnly = readOnly + } + } + /// Number of virtual CPUs. public let cpus: Int /// Memory in bytes. @@ -56,6 +75,8 @@ public struct MachineConfig: Codable, Sendable { public let virtualization: Bool /// Optional path to a custom kernel binary. nil falls back to the system default. public let kernelPath: FilePath? + /// Additional host directories bind-mounted into the container machine. + public let mounts: [Mount] private enum CodingKeys: String, CodingKey { case cpus @@ -63,6 +84,7 @@ public struct MachineConfig: Codable, Sendable { case homeMount case virtualization case kernelPath + case mounts } /// Settable keys and their descriptions, for CLI help text generation. @@ -79,13 +101,15 @@ public struct MachineConfig: Codable, Sendable { memory: MemorySize?, homeMount: HomeMountOption?, virtualization: Bool?, - kernelPath: FilePath? + kernelPath: FilePath?, + mounts: [Mount] = [] ) throws { self.cpus = cpus ?? Self.defaultCPUs self.memory = memory ?? Self.defaultMemory self.homeMount = homeMount ?? Self.defaultHomeMount self.virtualization = virtualization ?? false self.kernelPath = kernelPath + self.mounts = mounts try self.validate() } @@ -101,13 +125,24 @@ public struct MachineConfig: Codable, Sendable { // which the project's ConfigSnapshotDecoder can't handle. Persist as a plain String // and lift to FilePath in memory. let kernelPath = try container.decodeIfPresent(String.self, forKey: .kernelPath).map { FilePath($0) } + // Mounts are a per-machine value carried in boot-config.json (plain JSON). + // The system-wide `[machine]` section is read through ConfigSnapshotDecoder, + // which cannot represent arrays of structs, so skip the field on that path. + // Absent in boot configs written before user mounts existed; default to none. + let mounts: [Mount] + if decoder is ConfigSnapshotDecoderImpl { + mounts = [] + } else { + mounts = try container.decodeIfPresent([Mount].self, forKey: .mounts) ?? [] + } try self.init( cpus: cpus, memory: memory, homeMount: homeMount, virtualization: virtualization, - kernelPath: kernelPath) + kernelPath: kernelPath, + mounts: mounts) } public func encode(to encoder: any Encoder) throws { @@ -117,6 +152,9 @@ public struct MachineConfig: Codable, Sendable { try container.encode(homeMount, forKey: .homeMount) try container.encode(virtualization, forKey: .virtualization) try container.encodeIfPresent(kernelPath?.string, forKey: .kernelPath) + if !mounts.isEmpty { + try container.encode(mounts, forKey: .mounts) + } } private func validate() throws { @@ -133,6 +171,16 @@ public struct MachineConfig: Codable, Sendable { message: "invalid memory value '\(self.memory)'. Must be greater than 1gb." ) } + + var seenDestinations = Set() + for mount in self.mounts { + guard seenDestinations.insert(mount.destination).inserted else { + throw ContainerizationError( + .invalidArgument, + message: "duplicate mount destination '\(mount.destination)'" + ) + } + } } } @@ -146,9 +194,11 @@ extension MachineConfig { }.joined(separator: "\n") } - /// Create a new MachineConfig from `self`, applying fields defined in `kwargs` + /// Create a new MachineConfig from `self`, applying fields defined in `kwargs`. + /// Mount specifications are supplied separately because they are a repeatable + /// list rather than a scalar `key=value` setting. `nil` leaves mounts unchanged. /// This function is used in both `machine create` and `machine set` - public func with(_ kwargs: [String: String]) throws -> MachineConfig { + public func with(_ kwargs: [String: String], mounts mountSpecs: [String]? = nil) throws -> MachineConfig { let validKeys = Set(Self.settableKeys.map(\.key)) let unknownKeys = Set(kwargs.keys).subtracting(validKeys) guard unknownKeys.isEmpty else { @@ -169,12 +219,15 @@ extension MachineConfig { kernelPath = self.kernelPath } + let mounts = try mountSpecs.map { try $0.map { try Self.parseMount($0) } } + return try .init( cpus: cpus ?? self.cpus, memory: memory ?? self.memory, homeMount: homeMount ?? self.homeMount, virtualization: virtualization ?? self.virtualization, - kernelPath: kernelPath + kernelPath: kernelPath, + mounts: mounts ?? self.mounts ) } @@ -200,6 +253,55 @@ extension MachineConfig { return opt } + /// Parse a `host:guest[:ro|rw]` bind-mount specification into a `Mount`. + /// + /// The host path must be an existing directory; it and the guest path are + /// resolved to absolute paths. The optional third field selects the access + /// mode and defaults to read-write. + public static func parseMount(_ value: String) throws -> Mount { + let parts = value.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw ContainerizationError( + .invalidArgument, + message: "invalid mount '\(value)'. Expected 'host:guest' or 'host:guest:ro|rw'" + ) + } + + let readOnly: Bool + if parts.count == 3 { + switch parts[2] { + case "ro": readOnly = true + case "rw": readOnly = false + default: + throw ContainerizationError( + .invalidArgument, + message: "invalid mount mode '\(parts[2])' in '\(value)'. Valid options: ro, rw" + ) + } + } else { + readOnly = false + } + + let source = URL(fileURLWithPath: parts[0]).standardizedFileURL.path + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: source, isDirectory: &isDirectory) else { + throw ContainerizationError(.invalidArgument, message: "mount source '\(parts[0])' does not exist") + } + guard isDirectory.boolValue else { + throw ContainerizationError(.invalidArgument, message: "mount source '\(parts[0])' is not a directory") + } + + let destination = parts[1] + guard destination.hasPrefix("/") else { + throw ContainerizationError( + .invalidArgument, + message: "mount destination '\(destination)' must be an absolute path" + ) + } + + return Mount(source: source, destination: destination, readOnly: readOnly) + } + /// Parse a boolean setting accepting only "true" or "false". private static func parseBool(_ value: String, for key: String) throws -> Bool { guard let result = Parsers.parseBool(string: value) else { diff --git a/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift b/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift index 13e3ca9c7..a5ce970d3 100644 --- a/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift +++ b/Sources/ContainerTestSupport/ContainerFixture+MachineHelpers.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerPersistence import Foundation import Testing @@ -41,6 +42,7 @@ public struct MachineInspectOutput: Codable { public let cpus: Int public let memory: UInt64 public let homeMount: String? + public let mounts: [MachineConfig.Mount] public let diskSize: UInt64? public let ipAddress: String? diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index f641d3521..6c8c1a58c 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -364,6 +364,7 @@ public actor MachinesService { initializedFile: path.appending(MachineBundle.initializedFile), homeMountOption: bootConfig.homeMount, virtualization: bootConfig.virtualization, + mounts: bootConfig.mounts, ) config.resources.cpus = bootConfig.cpus @@ -639,6 +640,7 @@ extension MachineConfiguration { initializedFile: FilePath, homeMountOption: MachineConfig.HomeMountOption, virtualization: Bool, + mounts: [MachineConfig.Mount], ) async throws -> ContainerConfiguration { var config = ContainerConfiguration( id: cid, @@ -673,6 +675,15 @@ extension MachineConfiguration { ) ) } + for mount in mounts { + config.mounts.append( + .virtiofs( + source: mount.source, + destination: mount.destination, + options: [mount.readOnly ? "ro" : "rw"] + ) + ) + } config.platform = platform config.labels = [ diff --git a/Tests/ContainerPersistenceTests/MachineConfigTests.swift b/Tests/ContainerPersistenceTests/MachineConfigTests.swift index 8ea6e5155..ef7553d58 100644 --- a/Tests/ContainerPersistenceTests/MachineConfigTests.swift +++ b/Tests/ContainerPersistenceTests/MachineConfigTests.swift @@ -92,4 +92,106 @@ struct MachineConfigTests { #expect(keys.contains("virtualization")) #expect(keys.contains("kernel")) } + + // MARK: - Mounts + + /// Creates a temporary directory and removes it once `body` completes. + private func withTemporaryDirectory(_ body: (String) throws -> Void) throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + try body(url.path) + } + + @Test func mountsDefaultToEmpty() { + #expect(MachineConfig.default.mounts.isEmpty) + } + + @Test func withParsesReadWriteMount() throws { + try withTemporaryDirectory { source in + let updated = try MachineConfig.default.with([:], mounts: ["\(source):/data"]) + #expect(updated.mounts.count == 1) + #expect(updated.mounts[0].destination == "/data") + #expect(updated.mounts[0].readOnly == false) + } + } + + @Test func withParsesReadOnlyMount() throws { + try withTemporaryDirectory { source in + let updated = try MachineConfig.default.with([:], mounts: ["\(source):/data:ro"]) + #expect(updated.mounts[0].readOnly == true) + } + } + + @Test func withParsesMultipleMounts() throws { + try withTemporaryDirectory { source in + let updated = try MachineConfig.default.with([:], mounts: ["\(source):/a", "\(source):/b:ro"]) + #expect(updated.mounts.map(\.destination) == ["/a", "/b"]) + } + } + + @Test func nilMountsPreservesExisting() throws { + try withTemporaryDirectory { source in + let withMount = try MachineConfig.default.with([:], mounts: ["\(source):/data"]) + let unchanged = try withMount.with(["cpus": "4"]) + #expect(unchanged.mounts.count == 1) + #expect(unchanged.cpus == 4) + } + } + + @Test func withRejectsInvalidMountMode() throws { + try withTemporaryDirectory { source in + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with([:], mounts: ["\(source):/data:rx"]) + } + } + } + + @Test func withRejectsMissingSource() { + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with([:], mounts: ["/does/not/exist:/data"]) + } + } + + @Test func withRejectsRelativeDestination() throws { + try withTemporaryDirectory { source in + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with([:], mounts: ["\(source):data"]) + } + } + } + + @Test func withRejectsMalformedMount() throws { + try withTemporaryDirectory { source in + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with([:], mounts: [source]) + } + } + } + + @Test func withRejectsDuplicateDestinations() throws { + try withTemporaryDirectory { source in + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with([:], mounts: ["\(source):/data", "\(source):/data:ro"]) + } + } + } + + @Test func mountsRoundTripJSON() throws { + try withTemporaryDirectory { source in + let config = try MachineConfig.default.with([:], mounts: ["\(source):/data:ro"]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(MachineConfig.self, from: data) + #expect(decoded.mounts.count == 1) + #expect(decoded.mounts[0].destination == "/data") + #expect(decoded.mounts[0].readOnly == true) + } + } + + @Test func decodingMissingMountsUsesEmpty() throws { + // Boot configs written before user mounts existed have no `mounts` key. + let legacy = #"{"cpus":4,"memory":"1gb","homeMount":"rw"}"# + let decoded = try JSONDecoder().decode(MachineConfig.self, from: Data(legacy.utf8)) + #expect(decoded.mounts.isEmpty) + } } diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift index e670c642b..82147da52 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift @@ -33,6 +33,31 @@ struct TestCLIMachineCommand { } } + @Test func testCreateWithMounts() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + + let source = URL(fileURLWithPath: f.testDir.string).standardizedFileURL.path + try f.doMachineCreate( + name: name, + image: machineImage, + extraArgs: [ + "--mount", "\(source):/mnt/read-write:rw", + "--mount", "\(source):/mnt/read-only:ro", + ]) + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.mounts.count == 2) + #expect(snapshot.mounts[0].source == source) + #expect(snapshot.mounts[0].destination == "/mnt/read-write") + #expect(snapshot.mounts[0].readOnly == false) + #expect(snapshot.mounts[1].source == source) + #expect(snapshot.mounts[1].destination == "/mnt/read-only") + #expect(snapshot.mounts[1].readOnly == true) + } + } + @Test func testCreateRejectsDots() async throws { try await ContainerFixture.with { f in let result = try f.runMachine(["create", "--name", "my.bad.name", machineImage]) diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift index 6bd01cefd..1054bd67c 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift @@ -666,6 +666,42 @@ struct TestCLIMachineRuntimeSerial { } } + @Test func testUserMountsReadWriteAndReadOnly() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + + let readWriteSource = f.testDir.appending("read-write") + let readOnlySource = f.testDir.appending("read-only") + try FileManager.default.createDirectory( + atPath: readWriteSource.string, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + atPath: readOnlySource.string, withIntermediateDirectories: true) + + try f.doMachineCreate( + name: name, + image: machineImage, + extraArgs: [ + "--home-mount", "none", + "--mount", "\(readWriteSource.string):/mnt/read-write:rw", + "--mount", "\(readOnlySource.string):/mnt/read-only:ro", + ]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + try f.doMachineRun( + name: name, root: true, + command: ["touch", "/mnt/read-write/from-guest"]) + #expect(FileManager.default.fileExists(atPath: readWriteSource.appending("from-guest").string)) + + let readOnlyWrite = try f.runMachine([ + "run", "--root", "-n", name, "touch", "/mnt/read-only/blocked", + ]) + #expect(readOnlyWrite.status != 0) + #expect(!FileManager.default.fileExists(atPath: readOnlySource.appending("blocked").string)) + } + } + @Test func testCreateAutoBoots() async throws { try await ContainerFixture.with { f in let name = "\(f.testID)-machine" diff --git a/docs/command-reference.md b/docs/command-reference.md index 7a9a3a04d..7addb43d5 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1069,7 +1069,7 @@ container registry list [--format ] [--quiet] [--debug] ### `container machine create` -Creates a container machine from an image and boots it. Use `--cpus`, `--memory`, and `--home-mount` to configure it, or `--no-boot` to create it without booting. +Creates a container machine from an image and boots it. Use `--cpus`, `--memory`, `--home-mount`, and `--mount` to configure it, or `--no-boot` to create it without booting. **Usage** @@ -1089,6 +1089,7 @@ container machine create [] * `--cpus `: Number of virtual CPUs * `--memory `: Memory allocation (e.g., 2G, 8G). Default: half of system memory * `--home-mount `: User's home directory mount option (ro, rw, none). Default: rw +* `--mount `: Mount a host directory into the container machine. The host path must be an existing directory and the guest path must be absolute; the mode defaults to `rw`. Can be repeated. * `--virtualization`: Enable nested virtualization. Requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y. * `--kernel `: Path to a custom kernel binary (e.g. `vmlinux`). @@ -1124,6 +1125,9 @@ container machine create --no-boot alpine:3.22 # enable nested virtualization with a custom kernel built with CONFIG_KVM=y container machine create --virtualization --kernel ./vmlinux-kvm alpine:3.22 + +# mount additional host directories into the container machine +container machine create --mount /Volumes/Project:/Project --mount /tmp/data:/data:ro alpine:3.22 ``` ### `container machine run` diff --git a/docs/container-machine.md b/docs/container-machine.md index 81e36e562..b11b82ae1 100644 --- a/docs/container-machine.md +++ b/docs/container-machine.md @@ -72,6 +72,20 @@ container machine run -n dev -- nproc Memory defaults to half of host memory. The home-mount can be `rw` (default), `ro`, or `none`. +### Mount additional host directories + +Beyond your home directory, you can share arbitrary host directories into a container machine at create time with `--mount host:guest[:ro|rw]`. The host path must be an existing directory and the guest path must be absolute. The mode defaults to `rw`; pass `:ro` for a read-only mount. The flag can be repeated to add several mounts. + +```bash +# share a project directory read-write and a data set read-only +container machine create --name dev \ + --mount /Volumes/Project:/Project \ + --mount /Users/me/datasets:/data:ro \ + alpine:3.22 +``` + +Configured mounts are shown by `container machine inspect`. They are fixed for the lifetime of the machine; to change them, recreate the machine. + ### Nested virtualization and custom kernels A container machine supports nested virtualization. The requirements for this to work are: