add hotplug interfaces for vmms (#740)

Signed-off-by: michael_crosby <michael_crosby@apple.com>
This commit is contained in:
Michael Crosby authored and GitHub committed 2026-05-18 10:29:36 -07:00
1 parent f8a18e8dcd
commit 872f601f93
14 files changed
+465 -89

No files matched your search

+1 -1
View File
@@ -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"]),
@@ -31,7 +31,7 @@ public struct AttachedFilesystem: Sendable {
public init(mount: Mount, allocator: any AddressAllocator<Character>) 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()
+17 -32
View File
@@ -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<String> = []
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
}
}
+15 -3
View File
@@ -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))
}
@@ -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() {}
}
+23 -1
View File
@@ -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
+150 -22
View File
@@ -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
+3 -15
View File
@@ -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
}
@@ -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,
@@ -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<T: Sendable>(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T {
try _mounts.withLock(body)
}
/// Serialize VM operations with the instance lock.
public func withInstanceLock<T: Sendable>(_ 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<Character>,
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<Character>,
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<Character>) 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<Character>) 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)
}
}
@@ -77,6 +77,7 @@ public struct VZVirtualMachineManager: VirtualMachineManager {
instanceConfig.nestedVirtualization = useNestedVirtualization
instanceConfig.mountsByID = vmConfig.mountsByID
instanceConfig.extensions = vmConfig.extensions
})
}
}
@@ -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
}
}
+1 -1
View File
@@ -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
}
+3 -3
View File
@@ -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)
}
}