diff --git a/Package.swift b/Package.swift index 873cf465..37a899ef 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let package = Package( name: "containerization", - platforms: [.macOS("15")], + platforms: [.macOS("15.0")], products: [ .library(name: "Containerization", targets: ["Containerization", "ContainerizationError"]), .library(name: "ContainerizationEXT4", targets: ["ContainerizationEXT4"]), diff --git a/Sources/Containerization/AttachedFilesystem.swift b/Sources/Containerization/AttachedFilesystem.swift index e4291980..f9dbfe52 100644 --- a/Sources/Containerization/AttachedFilesystem.swift +++ b/Sources/Containerization/AttachedFilesystem.swift @@ -31,7 +31,7 @@ public struct AttachedFilesystem: Sendable { public init(mount: Mount, allocator: any AddressAllocator) throws { switch mount.runtimeOptions { case .virtiofs: - let name = try hashMountSource(source: mount.source) + let name = try hashFilePath(path: mount.source) self.source = name case .virtioblk: let char = try allocator.allocate() diff --git a/Sources/Containerization/FileMount.swift b/Sources/Containerization/FileMount.swift index 855011d1..518aaeee 100644 --- a/Sources/Containerization/FileMount.swift +++ b/Sources/Containerization/FileMount.swift @@ -133,7 +133,7 @@ extension FileMountContext { let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath() let filename = resolvedSource.lastPathComponent let parentDirectory = resolvedSource.deletingLastPathComponent() - let tag = try hashMountSource(source: parentDirectory.path) + let tag = try hashFilePath(path: parentDirectory.path) let prepared = PreparedMount( hostFilePath: mount.source, @@ -151,48 +151,33 @@ extension FileMountContext { } extension FileMountContext { - /// Mount the holding directories in the guest for all file mounts. + /// Set up the holding directory paths for all file mounts. + /// Since virtiofs shares are now mounted once at /run/virtiofs, the holding + /// directories appear as subdirectories there automatically. /// - Parameters: /// - vmMounts: The AttachedFilesystem array from the VM for this container - /// - agent: The VM agent for RPCs + /// - agent: The VM agent for RPCs (unused, kept for API compatibility) mutating func mountHoldingDirectories( vmMounts: [AttachedFilesystem], agent: any VirtualMachineAgent ) async throws { - // Track which tags we've already mounted to avoid duplicate mounts - // when multiple files share the same parent directory. - var mountedTags: Set = [] - for i in preparedMounts.indices { let prepared = preparedMounts[i] - let guestPath = "/run/file-mounts/\(prepared.tag)" - - if !mountedTags.contains(prepared.tag) { - // Find the attached filesystem by matching the virtiofs tag - guard - let attached = vmMounts.first(where: { - $0.type == "virtiofs" && $0.source == prepared.tag - }) - else { - throw ContainerizationError( - .notFound, - message: "could not find attached filesystem for file mount \(prepared.hostFilePath)" - ) - } - - try await agent.mkdir(path: guestPath, all: true, perms: 0o755) - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: attached.source, - destination: guestPath, - options: [] - )) - - mountedTags.insert(prepared.tag) + // Verify the attached filesystem exists + guard + vmMounts.first(where: { + $0.type == "virtiofs" && $0.source == prepared.tag + }) != nil + else { + throw ContainerizationError( + .notFound, + message: "could not find attached filesystem for file mount \(prepared.hostFilePath)" + ) } + // With unified virtiofs, holding directories are subdirectories under /run/virtiofs + let guestPath = "/run/virtiofs/\(prepared.tag)" preparedMounts[i].guestHoldingPath = guestPath } } diff --git a/Sources/Containerization/Hash.swift b/Sources/Containerization/Hash.swift index d7337b8f..e5088f45 100644 --- a/Sources/Containerization/Hash.swift +++ b/Sources/Containerization/Hash.swift @@ -18,11 +18,23 @@ import ContainerizationError import Crypto import Foundation -package func hashMountSource(source: String) throws -> String { +extension Mount { + /// A deterministic hash of the mount's source path, used as the virtiofs tag. + /// + /// Resolves symlinks before hashing so that different paths to the same + /// directory produce an identical tag. + public var tagHash: String { + get throws { + try hashFilePath(path: self.source) + } + } +} + +func hashFilePath(path: String) throws -> String { // Resolve symlinks so different paths to the same directory get the same hash. - let resolvedSource = URL(fileURLWithPath: source).resolvingSymlinksInPath().path + let resolvedSource = URL(fileURLWithPath: path).resolvingSymlinksInPath().path guard let data = resolvedSource.data(using: .utf8) else { - throw ContainerizationError(.invalidArgument, message: "\(source) could not be converted to Data") + throw ContainerizationError(.invalidArgument, message: "\(path) could not be converted to Data") } return String(SHA256.hash(data: data).encoded.prefix(36)) } diff --git a/Sources/Containerization/HotplugProvider.swift b/Sources/Containerization/HotplugProvider.swift new file mode 100644 index 00000000..f535ce3f --- /dev/null +++ b/Sources/Containerization/HotplugProvider.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// A provider that manages hotplug operations for a virtual machine instance. +/// +/// Conforming types implement the mechanics of hotplugging block devices and +/// virtiofs shares into a running VM. +public protocol HotplugProvider: Sendable { + /// Hotplug a block device into the running VM. + /// - Parameters: + /// - block: The mount configuration for the block device + /// - id: The container ID to associate with this device + /// - Returns: The attached filesystem with the device path in the guest + func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem + + /// Register mounts for a container in the VM's mount registry. + /// - Parameters: + /// - id: The container ID + /// - rootfs: The rootfs attachment from hotplug + /// - additionalMounts: Additional mounts to register + func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws + + /// Release a hotplug device. + /// - Parameter id: The container ID who should be released + func releaseHotplug(id: String) async throws + + /// Hotplug virtiofs directories into the running VM. + /// - Parameters: + /// - mounts: The virtiofs mounts to add + /// - id: The container ID that owns these mounts + func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws + + /// Release virtiofs shares for a container. + /// - Parameter id: The container ID whose shares should be released + func releaseVirtioFS(id: String) async throws + + /// Clean up resources held by the provider. + func cleanup() +} + +extension HotplugProvider { + public func cleanup() {} +} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 18f51e51..5e89d58d 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -591,6 +591,16 @@ extension LinuxContainer { try await vm.withAgent { agent in try await agent.standardSetup() + // Mount the unified virtiofs share at /run/virtiofs + // All virtiofs directories appear as subdirectories here + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: "virtiofs", + destination: "/run/virtiofs", + options: [] + )) + guard let attachments = vm.mounts[self.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found") } @@ -672,6 +682,7 @@ extension LinuxContainer { var spec = self.generateRuntimeSpec() // We don't need the rootfs (or writable layer), nor do OCI runtimes want it included. // Also filter out file mount holding directories. We'll mount those separately under /run. + // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} let containerMounts = createdState.vm.mounts[self.id] ?? [] let holdingTags = createdState.fileMountContext.holdingDirectoryTags // Drop rootfs, and writable layer if present. @@ -679,7 +690,18 @@ extension LinuxContainer { var mounts: [ContainerizationOCI.Mount] = containerMounts.dropFirst(mountsToSkip) .filter { !holdingTags.contains($0.source) } - .map { $0.to } + .map { attached -> ContainerizationOCI.Mount in + if attached.type == "virtiofs" { + // Transform to bind mount from holding directory + return ContainerizationOCI.Mount( + type: "none", + source: "/run/virtiofs/\(attached.source)", + destination: attached.destination, + options: ["bind"] + attached.options + ) + } + return attached.to + } + createdState.fileMountContext.ociBindMounts() // When useInit is enabled, bind mount vminitd from the VM's filesystem diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 6de30b8b..1ace38b6 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -63,6 +63,8 @@ public final class LinuxPod: Sendable { public var hosts: Hosts? /// Volumes attached to the pod. Can be shared with multiple containers. public var volumes: [PodVolume] = [] + /// Extension objects that participate in the VM instance lifecycle. + public var extensions: [any Sendable] = [] public init() {} } @@ -300,11 +302,11 @@ public final class LinuxPod: Sendable { return spec } - private static func guestRootfsPath(_ containerID: String) -> String { + static func guestRootfsPath(_ containerID: String) -> String { "/run/container/\(containerID)/rootfs" } - private static func guestSocketStagingPath(_ socketID: String) -> String { + static func guestSocketStagingPath(_ socketID: String) -> String { "/run/sockets/\(socketID).sock" } @@ -329,8 +331,11 @@ extension LinuxPod { config.interfaces } - /// Add a container to the pod. This must be called before `create()`. - /// The container will be registered but not started. + /// Add a container to the pod. + /// + /// When called before `create()`, the container is registered for setup during VM creation. + /// When called after `create()`, the container is hotplugged into the running VM. + /// If the underlying VMM does not support hotplug, an error is thrown. public func addContainer( _ id: String, rootfs: Mount, @@ -343,13 +348,6 @@ extension LinuxPod { ) } try await self.state.withLock { state in - guard case .initialized = state.phase else { - throw ContainerizationError( - .invalidState, - message: "pod must be initialized to add container" - ) - } - guard state.containers[id] == nil else { throw ContainerizationError( .invalidArgument, @@ -360,17 +358,104 @@ extension LinuxPod { var config = ContainerConfiguration() try configuration(&config) - // Prepare file mounts - transforms single-file mounts into directory shares. let fileMountContext = try FileMountContext.prepare(mounts: config.mounts) - state.containers[id] = PodContainer( - id: id, - rootfs: rootfs, - config: config, - state: .registered, - process: nil, - fileMountContext: fileMountContext - ) + switch state.phase { + case .initialized: + state.containers[id] = PodContainer( + id: id, + rootfs: rootfs, + config: config, + state: .registered, + process: nil, + fileMountContext: fileMountContext + ) + + case .created(let createdState): + let vm = createdState.vm + + var modifiedRootfs = rootfs + modifiedRootfs.options.removeAll(where: { $0 == "ro" }) + + let attachment = try await vm.hotplug(modifiedRootfs, id: id) + + var updatedFileMountContext = fileMountContext + do { + let virtioFSMounts = fileMountContext.transformedMounts.filter { + if case .virtiofs(_) = $0.runtimeOptions { return true } + return false + } + if !virtioFSMounts.isEmpty { + try await vm.hotplugVirtioFS(virtioFSMounts, id: id) + } + + let agent = try await vm.dialAgent() + do { + var mount = attachment.to + mount.destination = Self.guestRootfsPath(id) + try await agent.mount(mount) + + try vm.registerMounts( + id: id, + rootfs: attachment, + additionalMounts: fileMountContext.transformedMounts + ) + + if fileMountContext.hasFileMounts { + let containerMounts = vm.mounts[id] ?? [] + try await updatedFileMountContext.mountHoldingDirectories( + vmMounts: containerMounts, + agent: agent + ) + } + + if let dns = config.dns ?? self.config.dns { + try await agent.configureDNS( + config: dns, + location: Self.guestRootfsPath(id) + ) + } + + if let hosts = config.hosts ?? self.config.hosts { + try await agent.configureHosts( + config: hosts, + location: Self.guestRootfsPath(id) + ) + } + + for socket in config.sockets { + try await self.relayUnixSocket( + socket: socket, + containerID: id, + relayManager: createdState.relayManager, + agent: agent + ) + } + + try await agent.close() + } catch { + try? await agent.umount(path: Self.guestRootfsPath(id), flags: 0) + try? await agent.close() + throw error + } + + state.containers[id] = PodContainer( + id: id, + rootfs: rootfs, + config: config, + state: .created, + process: nil, + fileMountContext: updatedFileMountContext + ) + } catch { + try? await vm.releaseHotplug(id: id) + try? await vm.releaseVirtioFS(id: id) + throw error + } + + case .errored(let err): + throw err + } } } @@ -426,7 +511,7 @@ extension LinuxPod { mountsByID[self.id] = podVolumeMounts } - let vmConfig = VMConfiguration( + var vmConfig = VMConfiguration( cpus: self.config.cpus, memoryInBytes: self.config.memoryInBytes, interfaces: self.config.interfaces, @@ -434,6 +519,7 @@ extension LinuxPod { bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) + vmConfig.extensions = self.config.extensions let creationConfig = StandardVMConfig(configuration: vmConfig) let vm = try await self.vmm.create(config: creationConfig) let relayManager = UnixSocketRelayManager(vm: vm) @@ -448,6 +534,17 @@ extension LinuxPod { try await vm.withAgent { agent in try await agent.standardSetup() + // Mount the unified virtiofs share at /run/virtiofs + // All virtiofs directories appear as subdirectories here + try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: "virtiofs", + destination: "/run/virtiofs", + options: [] + )) + // Create pause container if PID namespace sharing is enabled if shareProcessNamespace { let pauseID = "pause-\(self.id)" @@ -646,12 +743,24 @@ extension LinuxPod { var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs) // We don't need the rootfs, nor do OCI runtimes want it included. // Also filter out file mount holding directories - we mount those separately under /run. + // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} let containerMounts = createdState.vm.mounts[containerID] ?? [] let holdingTags = container.fileMountContext.holdingDirectoryTags var mounts: [ContainerizationOCI.Mount] = containerMounts.dropFirst() .filter { !holdingTags.contains($0.source) } - .map { $0.to } + .map { attached -> ContainerizationOCI.Mount in + if attached.type == "virtiofs" { + // Transform to bind mount from holding directory + return ContainerizationOCI.Mount( + type: "none", + source: "/run/virtiofs/\(attached.source)", + destination: attached.destination, + options: ["bind"] + attached.options + ) + } + return attached.to + } + container.fileMountContext.ociBindMounts() // When useInit is enabled, bind mount vminitd from the VM's filesystem @@ -767,6 +876,17 @@ extension LinuxPod { return } + // Handle containers that were hotplugged but never started + if container.state == .created { + // Release the hotplug device and virtiofs shares + try? await createdState.vm.releaseHotplug(id: containerID) + try? await createdState.vm.releaseVirtioFS(id: containerID) + + container.state = .stopped + state.containers[containerID] = container + return + } + guard container.state == .started, let process = container.process else { throw ContainerizationError( .invalidState, @@ -793,6 +913,10 @@ extension LinuxPod { ) } + // Release the hotplug device and virtiofs shares so they can be reused by new containers + try await createdState.vm.releaseHotplug(id: containerID) + try await createdState.vm.releaseVirtioFS(id: containerID) + // Clean up the process resources try await process.delete() @@ -800,6 +924,10 @@ extension LinuxPod { container.state = .stopped state.containers[containerID] = container } catch { + // Try to release the hotplug device and virtiofs shares even on error + try? await createdState.vm.releaseHotplug(id: containerID) + try? await createdState.vm.releaseVirtioFS(id: containerID) + container.state = .errored container.process = nil state.containers[containerID] = container diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index 289065a2..b72436bd 100644 --- a/Sources/Containerization/Mount.swift +++ b/Sources/Containerization/Mount.swift @@ -176,21 +176,9 @@ extension Mount { let attachment = VZVirtioBlockDeviceConfiguration(attachment: device) config.storageDevices.append(attachment) case .virtiofs(_): - guard FileManager.default.fileExists(atPath: self.source) else { - throw ContainerizationError(.notFound, message: "directory \(source) does not exist") - } - - let name = try hashMountSource(source: self.source) - let urlSource = URL(fileURLWithPath: source) - - let device = VZVirtioFileSystemDeviceConfiguration(tag: name) - device.share = VZSingleDirectoryShare( - directory: VZSharedDirectory( - url: urlSource, - readOnly: readonly - ) - ) - config.directorySharingDevices.append(device) + // VirtioFS mounts are handled centrally via VZMultipleDirectoryShare in VZVirtualMachineInstance + // No per-mount device configuration needed + break case .shared, .any: break } diff --git a/Sources/Containerization/VMConfiguration.swift b/Sources/Containerization/VMConfiguration.swift index 69abc273..30faebc4 100644 --- a/Sources/Containerization/VMConfiguration.swift +++ b/Sources/Containerization/VMConfiguration.swift @@ -80,6 +80,10 @@ public struct VMConfiguration: Sendable { /// Enable nested virtualization support. If the VirtualMachineManager /// does not support this feature, it MUST return an .unsupported ContainerizationError. public var nestedVirtualization: Bool + /// Extension objects that participate in the VM instance lifecycle. + /// Extension packages append their types here; VZ-aware extensions + /// should conform to ``VZInstanceExtension``. + public var extensions: [any Sendable] = [] public init( cpus: Int = 4, diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 51c8acc1..a711b2b5 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -23,13 +23,39 @@ import Logging import NIOCore import NIOPosix import Synchronization -import Virtualization +@preconcurrency import Virtualization public final class VZVirtualMachineInstance: Sendable { public typealias Agent = Vminitd /// Attached mounts on the virtual machine, organized by metadata ID. - public let mounts: [String: [AttachedFilesystem]] + private let _mounts: Mutex<[String: [AttachedFilesystem]]> + public var mounts: [String: [AttachedFilesystem]] { + _mounts.withLock { $0 } + } + + /// The underlying Virtualization framework virtual machine. + public var vzVirtualMachine: VZVirtualMachine { vm } + + /// The dispatch queue used for VZ operations. + public var vmQueue: DispatchQueue { queue } + + /// Mutate the mount registry. + public func withMountRegistry(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { + try _mounts.withLock(body) + } + + /// Serialize VM operations with the instance lock. + public func withInstanceLock(_ body: @Sendable @escaping () async throws -> T) async throws -> T { + try await lock.withLock { _ in try await body() } + } + + /// The hotplug provider, if hotplug is enabled for this instance. + public var hotplugProvider: (any HotplugProvider)? { + get { _hotplugProvider.withLock { $0 } } + set { _hotplugProvider.withLock { $0 = newValue } } + } + private let _hotplugProvider = Mutex<(any HotplugProvider)?>(nil) /// Returns the runtime state of the vm. public var state: VirtualMachineInstanceState { @@ -57,6 +83,8 @@ public final class VZVirtualMachineInstance: Sendable { public var initialFilesystem: Mount? /// Destination for the virtual machine's boot logs. public var bootLog: BootLog? + /// Extension objects that participate in the VM instance lifecycle. + public var extensions: [any Sendable] = [] public init() { self.cpus = 4 @@ -99,17 +127,55 @@ public final class VZVirtualMachineInstance: Sendable { self.config = config self.lock = .init() self.queue = DispatchQueue(label: "com.apple.containerization.vzvm.\(UUID().uuidString)") - self.mounts = try config.mountAttachments() self.logger = logger self.timeSyncer = .init(logger: logger) + let allocator = Character.blockDeviceTagAllocator() + let (mountAttachments, _) = try config.mountAttachments(allocator: allocator) + self._mounts = Mutex(mountAttachments) + self.vm = VZVirtualMachine( - configuration: try config.toVZ(), + configuration: try config.toVZ(allocator: allocator), queue: self.queue ) + + for ext in config.extensions.compactMap({ $0 as? any VZInstanceExtension }) { + try ext.didCreate(self) + } } } +/// Protocol for extensions that participate in VZVirtualMachineInstance lifecycle. +/// Append conforming types to `Configuration.extensions` to hook into VM setup and teardown. +public protocol VZInstanceExtension: Sendable { + /// Modify the VZ configuration before the VM is created. + func configureVZ( + _ config: inout VZVirtualMachineConfiguration, + allocator: any AddressAllocator, + storageDeviceCount: Int, + mountsByID: [String: [Mount]] + ) throws + + /// Called after the VZVirtualMachine is created but before start. + func didCreate(_ instance: VZVirtualMachineInstance) throws + + /// Called during stop before the VM is shut down. + func willStop(_ instance: VZVirtualMachineInstance) async throws +} + +extension VZInstanceExtension { + public func configureVZ( + _ config: inout VZVirtualMachineConfiguration, + allocator: any AddressAllocator, + storageDeviceCount: Int, + mountsByID: [String: [Mount]] + ) throws {} + + public func didCreate(_ instance: VZVirtualMachineInstance) throws {} + + public func willStop(_ instance: VZVirtualMachineInstance) async throws {} +} + extension VZVirtualMachineInstance: VirtualMachineInstance { public func start() async throws { try await lock.withLock { _ in @@ -161,6 +227,10 @@ extension VZVirtualMachineInstance: VirtualMachineInstance { try await self.group.shutdownGracefully() } + for ext in self.config.extensions.compactMap({ $0 as? any VZInstanceExtension }) { + try? await ext.willStop(self) + } + try await self.vm.stop(queue: self.queue) } } @@ -244,6 +314,35 @@ extension VZVirtualMachineInstance: VirtualMachineInstance { port: port ) } + + // MARK: - Hotplug + + public func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem { + guard let hotplugProvider else { + throw ContainerizationError(.unsupported, message: "hotplug not supported") + } + return try await hotplugProvider.hotplug(block, id: id) + } + + public func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { + guard let hotplugProvider else { return } + try hotplugProvider.registerMounts(id: id, rootfs: rootfs, additionalMounts: additionalMounts) + } + + public func releaseHotplug(id: String) async throws { + guard let hotplugProvider else { return } + try await hotplugProvider.releaseHotplug(id: id) + } + + public func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { + guard let hotplugProvider else { return } + try await hotplugProvider.hotplugVirtioFS(mounts, id: id) + } + + public func releaseVirtioFS(id: String) async throws { + guard let hotplugProvider else { return } + try await hotplugProvider.releaseVirtioFS(id: id) + } } extension VZVirtualMachineInstance { @@ -311,7 +410,7 @@ extension VZVirtualMachineInstance.Configuration { return [c] } - func toVZ() throws -> VZVirtualMachineConfiguration { + func toVZ(allocator: any AddressAllocator) throws -> VZVirtualMachineConfiguration { var config = VZVirtualMachineConfiguration() config.cpuCount = self.cpus @@ -382,7 +481,7 @@ extension VZVirtualMachineInstance.Configuration { for (_, mounts) in self.mountsByID { for mount in mounts { if case .virtiofs = mount.runtimeOptions { - let tag = try hashMountSource(source: mount.source) + let tag = try hashFilePath(path: mount.source) if usedVirtioFSTags.contains(tag) { continue } @@ -392,6 +491,29 @@ extension VZVirtualMachineInstance.Configuration { } } + // Create the unified virtiofs device with VZMultipleDirectoryShare + // This device hosts all virtiofs shares and supports runtime updates + var directories: [String: VZSharedDirectory] = [:] + for (_, mounts) in self.mountsByID { + for mount in mounts { + guard case .virtiofs(_) = mount.runtimeOptions else { continue } + guard FileManager.default.fileExists(atPath: mount.source) else { + throw ContainerizationError(.notFound, message: "directory \(mount.source) does not exist") + } + let name = try hashFilePath(path: mount.source) + directories[name] = VZSharedDirectory( + url: URL(fileURLWithPath: mount.source), + readOnly: mount.options.contains("ro") + ) + } + } + let multiShare = VZMultipleDirectoryShare(directories: directories) + let virtiofsDevice = VZVirtioFileSystemDeviceConfiguration(tag: "virtiofs") + virtiofsDevice.share = multiShare + config.directorySharingDevices.append(virtiofsDevice) + + let storageDeviceCount = config.storageDevices.count + let platform = VZGenericPlatformConfiguration() // We shouldn't silently succeed if the user asked for virt and their hardware does // not support it. @@ -404,29 +526,43 @@ extension VZVirtualMachineInstance.Configuration { platform.isNestedVirtualizationEnabled = self.nestedVirtualization config.platform = platform + for ext in self.extensions.compactMap({ $0 as? any VZInstanceExtension }) { + try ext.configureVZ(&config, allocator: allocator, storageDeviceCount: storageDeviceCount, mountsByID: self.mountsByID) + } + try config.validate() return config } - func mountAttachments() throws -> [String: [AttachedFilesystem]] { - let allocator = Character.blockDeviceTagAllocator() + func mountAttachments(allocator: any AddressAllocator) throws -> ( + attachments: [String: [AttachedFilesystem]], storageDeviceCount: Int + ) { + var storageDeviceCount = 0 + if let initialFilesystem { // When the initial filesystem is a blk, allocate the first letter "vd(a)" // as that is what this blk will be attached under. if initialFilesystem.isBlock { _ = try allocator.allocate() + storageDeviceCount += 1 } } var attachmentsByID: [String: [AttachedFilesystem]] = [:] + for (id, mounts) in self.mountsByID { var attachments: [AttachedFilesystem] = [] for mount in mounts { - attachments.append(try .init(mount: mount, allocator: allocator)) + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + attachments.append(attached) + if mount.isBlock { + storageDeviceCount += 1 + } } attachmentsByID[id] = attachments } - return attachmentsByID + + return (attachmentsByID, storageDeviceCount) } } diff --git a/Sources/Containerization/VZVirtualMachineManager.swift b/Sources/Containerization/VZVirtualMachineManager.swift index fb560357..4959bee4 100644 --- a/Sources/Containerization/VZVirtualMachineManager.swift +++ b/Sources/Containerization/VZVirtualMachineManager.swift @@ -77,6 +77,7 @@ public struct VZVirtualMachineManager: VirtualMachineManager { instanceConfig.nestedVirtualization = useNestedVirtualization instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.extensions = vmConfig.extensions }) } } diff --git a/Sources/Containerization/VirtualMachineInstance.swift b/Sources/Containerization/VirtualMachineInstance.swift index 8737730b..1b834384 100644 --- a/Sources/Containerization/VirtualMachineInstance.swift +++ b/Sources/Containerization/VirtualMachineInstance.swift @@ -49,6 +49,35 @@ public protocol VirtualMachineInstance: Sendable { func pause() async throws /// Resume the virtual machine. func resume() async throws + + /// Hotplug a block device, returning the attached filesystem info. + /// Throws if the VMM does not support hotplug or not available + /// - Parameter block: The mount configuration for the block device to hotplug + /// - Parameter id: The metadata ID to associate with this mount (e.g. container ID) + /// - Returns: AttachedFilesystem with the device path in the guest + func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem + + /// Register mounts for a container after hotplug. + /// This is used to add the rootfs and additional mounts to the VM's mount registry + /// so they can be found when building the container's OCI spec. + /// - Parameter id: The container ID + /// - Parameter rootfs: The rootfs attachment from hotplug + /// - Parameter additionalMounts: Additional mounts (like /proc, /sys) to register + func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws + + /// Release a hotplug device. + /// This should be called when a hotplugged container is stopped or fails to start. + /// - Parameter id: The container ID whose hotplug should be released + func releaseHotplug(id: String) async throws + + /// Hotplug virtiofs directories into the running VM. + /// - Parameter mounts: The virtiofs mounts to add + /// - Parameter id: The container ID that owns these mounts + func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws + + /// Release virtiofs shares for a container. + /// - Parameter id: The container ID whose virtiofs shares should be released + func releaseVirtioFS(id: String) async throws } extension VirtualMachineInstance { @@ -58,4 +87,19 @@ extension VirtualMachineInstance { public func resume() async throws { throw ContainerizationError(.unsupported, message: "resume") } + public func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem { + throw ContainerizationError(.unsupported, message: "hotplug not supported") + } + public func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { + // no-op default + } + public func releaseHotplug(id: String) async throws { + // no-op default + } + public func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { + // no-op default + } + public func releaseVirtioFS(id: String) async throws { + // no-op default + } } diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index a17bfea5..42b43df6 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -495,7 +495,7 @@ extension IntegrationSuite { let bs = try await bootstrap(id) let pod = try LinuxPod(id, vmm: bs.vmm) { config in config.cpus = 4 - config.memoryInBytes = 1_000_000_000 + config.memoryInBytes = 1024.mib() config.bootLog = bs.bootLog } diff --git a/Tests/ContainerizationTests/HashTests.swift b/Tests/ContainerizationTests/HashTests.swift index dfa52b04..e0d33efb 100644 --- a/Tests/ContainerizationTests/HashTests.swift +++ b/Tests/ContainerizationTests/HashTests.swift @@ -22,17 +22,17 @@ import Testing struct HashTests { @Test func hashMountSourceWithValidString() throws { - let result = try hashMountSource(source: "/valid/path") + let result = try hashFilePath(path: "/valid/path") // Should produce a non-empty hash #expect(!result.isEmpty) // Same input should produce same hash (deterministic) - let result2 = try hashMountSource(source: "/valid/path") + let result2 = try hashFilePath(path: "/valid/path") #expect(result == result2) // Different inputs should produce different hashes - let result3 = try hashMountSource(source: "/different/path") + let result3 = try hashFilePath(path: "/different/path") #expect(result != result3) } }