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
8 changes: 7 additions & 1 deletion Sources/ContainerCommands/Machine/MachineCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/ContainerCommands/Machine/MachineInspect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
}
Expand Down
112 changes: 107 additions & 5 deletions Sources/ContainerPersistence/MachineConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -56,13 +75,16 @@ 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
case memory
case homeMount
case virtualization
case kernelPath
case mounts
}

/// Settable keys and their descriptions, for CLI help text generation.
Expand All @@ -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()
}
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -133,6 +171,16 @@ public struct MachineConfig: Codable, Sendable {
message: "invalid memory value '\(self.memory)'. Must be greater than 1gb."
)
}

var seenDestinations = Set<String>()
for mount in self.mounts {
guard seenDestinations.insert(mount.destination).inserted else {
throw ContainerizationError(
.invalidArgument,
message: "duplicate mount destination '\(mount.destination)'"
)
}
}
}
}

Expand All @@ -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 {
Expand All @@ -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
)
}

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerPersistence
import Foundation
import Testing

Expand Down Expand Up @@ -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?

Expand Down
11 changes: 11 additions & 0 deletions Sources/Services/MachineAPIService/Server/MachinesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -639,6 +640,7 @@ extension MachineConfiguration {
initializedFile: FilePath,
homeMountOption: MachineConfig.HomeMountOption,
virtualization: Bool,
mounts: [MachineConfig.Mount],
) async throws -> ContainerConfiguration {
var config = ContainerConfiguration(
id: cid,
Expand Down Expand Up @@ -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 = [
Expand Down
102 changes: 102 additions & 0 deletions Tests/ContainerPersistenceTests/MachineConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading