mirror of
https://github.com/apple/container.git
synced 2026-10-13 09:39:18 +00:00
add hotplug interfaces for vmms (#740)
Signed-off-by: michael_crosby <michael_crosby@apple.com>
This commit is contained in:
1 parent
f8a18e8dcd
commit
872f601f93
14 files changed
+465
-89
No files matched your search
+1
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user