Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down
2 changes: 1 addition & 1 deletion Sources/Containerization/AttachedFilesystem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
49 changes: 17 additions & 32 deletions Sources/Containerization/FileMount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
Expand Down
18 changes: 15 additions & 3 deletions Sources/Containerization/Hash.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
56 changes: 56 additions & 0 deletions Sources/Containerization/HotplugProvider.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
24 changes: 23 additions & 1 deletion Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -672,14 +682,26 @@ 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.
let mountsToSkip = self.writableLayer != nil ? 2 : 1
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
Expand Down
Loading
Loading