Skip to content

Commit 872f601

Browse files
add hotplug interfaces for vmms (#740)
Signed-off-by: michael_crosby <michael_crosby@apple.com>
1 parent f8a18e8 commit 872f601

14 files changed

Lines changed: 465 additions & 89 deletions

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import PackageDescription
2323

2424
let package = Package(
2525
name: "containerization",
26-
platforms: [.macOS("15")],
26+
platforms: [.macOS("15.0")],
2727
products: [
2828
.library(name: "Containerization", targets: ["Containerization", "ContainerizationError"]),
2929
.library(name: "ContainerizationEXT4", targets: ["ContainerizationEXT4"]),

Sources/Containerization/AttachedFilesystem.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public struct AttachedFilesystem: Sendable {
3131
public init(mount: Mount, allocator: any AddressAllocator<Character>) throws {
3232
switch mount.runtimeOptions {
3333
case .virtiofs:
34-
let name = try hashMountSource(source: mount.source)
34+
let name = try hashFilePath(path: mount.source)
3535
self.source = name
3636
case .virtioblk:
3737
let char = try allocator.allocate()

Sources/Containerization/FileMount.swift

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ extension FileMountContext {
133133
let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath()
134134
let filename = resolvedSource.lastPathComponent
135135
let parentDirectory = resolvedSource.deletingLastPathComponent()
136-
let tag = try hashMountSource(source: parentDirectory.path)
136+
let tag = try hashFilePath(path: parentDirectory.path)
137137

138138
let prepared = PreparedMount(
139139
hostFilePath: mount.source,
@@ -151,48 +151,33 @@ extension FileMountContext {
151151
}
152152

153153
extension FileMountContext {
154-
/// Mount the holding directories in the guest for all file mounts.
154+
/// Set up the holding directory paths for all file mounts.
155+
/// Since virtiofs shares are now mounted once at /run/virtiofs, the holding
156+
/// directories appear as subdirectories there automatically.
155157
/// - Parameters:
156158
/// - vmMounts: The AttachedFilesystem array from the VM for this container
157-
/// - agent: The VM agent for RPCs
159+
/// - agent: The VM agent for RPCs (unused, kept for API compatibility)
158160
mutating func mountHoldingDirectories(
159161
vmMounts: [AttachedFilesystem],
160162
agent: any VirtualMachineAgent
161163
) async throws {
162-
// Track which tags we've already mounted to avoid duplicate mounts
163-
// when multiple files share the same parent directory.
164-
var mountedTags: Set<String> = []
165-
166164
for i in preparedMounts.indices {
167165
let prepared = preparedMounts[i]
168166

169-
let guestPath = "/run/file-mounts/\(prepared.tag)"
170-
171-
if !mountedTags.contains(prepared.tag) {
172-
// Find the attached filesystem by matching the virtiofs tag
173-
guard
174-
let attached = vmMounts.first(where: {
175-
$0.type == "virtiofs" && $0.source == prepared.tag
176-
})
177-
else {
178-
throw ContainerizationError(
179-
.notFound,
180-
message: "could not find attached filesystem for file mount \(prepared.hostFilePath)"
181-
)
182-
}
183-
184-
try await agent.mkdir(path: guestPath, all: true, perms: 0o755)
185-
try await agent.mount(
186-
ContainerizationOCI.Mount(
187-
type: "virtiofs",
188-
source: attached.source,
189-
destination: guestPath,
190-
options: []
191-
))
192-
193-
mountedTags.insert(prepared.tag)
167+
// Verify the attached filesystem exists
168+
guard
169+
vmMounts.first(where: {
170+
$0.type == "virtiofs" && $0.source == prepared.tag
171+
}) != nil
172+
else {
173+
throw ContainerizationError(
174+
.notFound,
175+
message: "could not find attached filesystem for file mount \(prepared.hostFilePath)"
176+
)
194177
}
195178

179+
// With unified virtiofs, holding directories are subdirectories under /run/virtiofs
180+
let guestPath = "/run/virtiofs/\(prepared.tag)"
196181
preparedMounts[i].guestHoldingPath = guestPath
197182
}
198183
}

Sources/Containerization/Hash.swift

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,23 @@ import ContainerizationError
1818
import Crypto
1919
import Foundation
2020

21-
package func hashMountSource(source: String) throws -> String {
21+
extension Mount {
22+
/// A deterministic hash of the mount's source path, used as the virtiofs tag.
23+
///
24+
/// Resolves symlinks before hashing so that different paths to the same
25+
/// directory produce an identical tag.
26+
public var tagHash: String {
27+
get throws {
28+
try hashFilePath(path: self.source)
29+
}
30+
}
31+
}
32+
33+
func hashFilePath(path: String) throws -> String {
2234
// Resolve symlinks so different paths to the same directory get the same hash.
23-
let resolvedSource = URL(fileURLWithPath: source).resolvingSymlinksInPath().path
35+
let resolvedSource = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
2436
guard let data = resolvedSource.data(using: .utf8) else {
25-
throw ContainerizationError(.invalidArgument, message: "\(source) could not be converted to Data")
37+
throw ContainerizationError(.invalidArgument, message: "\(path) could not be converted to Data")
2638
}
2739
return String(SHA256.hash(data: data).encoded.prefix(36))
2840
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
/// A provider that manages hotplug operations for a virtual machine instance.
18+
///
19+
/// Conforming types implement the mechanics of hotplugging block devices and
20+
/// virtiofs shares into a running VM.
21+
public protocol HotplugProvider: Sendable {
22+
/// Hotplug a block device into the running VM.
23+
/// - Parameters:
24+
/// - block: The mount configuration for the block device
25+
/// - id: The container ID to associate with this device
26+
/// - Returns: The attached filesystem with the device path in the guest
27+
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem
28+
29+
/// Register mounts for a container in the VM's mount registry.
30+
/// - Parameters:
31+
/// - id: The container ID
32+
/// - rootfs: The rootfs attachment from hotplug
33+
/// - additionalMounts: Additional mounts to register
34+
func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws
35+
36+
/// Release a hotplug device.
37+
/// - Parameter id: The container ID who should be released
38+
func releaseHotplug(id: String) async throws
39+
40+
/// Hotplug virtiofs directories into the running VM.
41+
/// - Parameters:
42+
/// - mounts: The virtiofs mounts to add
43+
/// - id: The container ID that owns these mounts
44+
func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws
45+
46+
/// Release virtiofs shares for a container.
47+
/// - Parameter id: The container ID whose shares should be released
48+
func releaseVirtioFS(id: String) async throws
49+
50+
/// Clean up resources held by the provider.
51+
func cleanup()
52+
}
53+
54+
extension HotplugProvider {
55+
public func cleanup() {}
56+
}

Sources/Containerization/LinuxContainer.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,16 @@ extension LinuxContainer {
591591
try await vm.withAgent { agent in
592592
try await agent.standardSetup()
593593

594+
// Mount the unified virtiofs share at /run/virtiofs
595+
// All virtiofs directories appear as subdirectories here
596+
try await agent.mount(
597+
ContainerizationOCI.Mount(
598+
type: "virtiofs",
599+
source: "virtiofs",
600+
destination: "/run/virtiofs",
601+
options: []
602+
))
603+
594604
guard let attachments = vm.mounts[self.id] else {
595605
throw ContainerizationError(.notFound, message: "rootfs mount not found")
596606
}
@@ -672,14 +682,26 @@ extension LinuxContainer {
672682
var spec = self.generateRuntimeSpec()
673683
// We don't need the rootfs (or writable layer), nor do OCI runtimes want it included.
674684
// Also filter out file mount holding directories. We'll mount those separately under /run.
685+
// Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag}
675686
let containerMounts = createdState.vm.mounts[self.id] ?? []
676687
let holdingTags = createdState.fileMountContext.holdingDirectoryTags
677688
// Drop rootfs, and writable layer if present.
678689
let mountsToSkip = self.writableLayer != nil ? 2 : 1
679690
var mounts: [ContainerizationOCI.Mount] =
680691
containerMounts.dropFirst(mountsToSkip)
681692
.filter { !holdingTags.contains($0.source) }
682-
.map { $0.to }
693+
.map { attached -> ContainerizationOCI.Mount in
694+
if attached.type == "virtiofs" {
695+
// Transform to bind mount from holding directory
696+
return ContainerizationOCI.Mount(
697+
type: "none",
698+
source: "/run/virtiofs/\(attached.source)",
699+
destination: attached.destination,
700+
options: ["bind"] + attached.options
701+
)
702+
}
703+
return attached.to
704+
}
683705
+ createdState.fileMountContext.ociBindMounts()
684706

685707
// When useInit is enabled, bind mount vminitd from the VM's filesystem

0 commit comments

Comments
 (0)