Files
apple--containerization/Sources/Containerization/CHHotplugProvider.swift
T
wehub-resource-sync 680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

423 lines
16 KiB
Swift

//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#if os(Linux)
import CloudHypervisor
import ContainerizationError
import ContainerizationExtras
import Foundation
import Logging
import NIOHTTP1
import Synchronization
/// Hotplug provider for the cloud-hypervisor backend.
///
/// Handles both block (`vm.add-disk`) and virtiofs (`vm.add-fs`, with one
/// `virtiofsd` per unique source-hash tag) hotplug, plus the matching
/// `vm.remove-device` teardown. Owns the per-VM mount registry so
/// `CHVirtualMachineInstance.mounts` can forward to it.
final class CHHotplugProvider: HotplugProvider {
struct HotplugRecord: Sendable {
let chDeviceId: String
let kind: Kind
enum Kind: Sendable {
case block(letter: Character)
case virtiofs(tag: String)
}
}
struct VirtiofsdTagState: Sendable {
var process: VirtiofsdProcess
var refcount: Int
var chDeviceId: String
}
private let client: CloudHypervisor.Client
private let workDir: URL
private let virtiofsdBinaryOverride: URL?
private let allocator: any AddressAllocator<Character>
private let _mounts: Mutex<[String: [AttachedFilesystem]]>
private let _records: Mutex<[String: [HotplugRecord]]>
private let _tags: Mutex<[String: VirtiofsdTagState]>
/// Serializes per-tag virtiofsd spawn so a concurrent hotplug for the
/// same tag can't race the existence-check / process-registration window
/// (TOCTOU → orphaned virtiofsd). Held across awaits, so it must be an
/// `AsyncLock` rather than the sync `Mutex` that protects `_tags`.
private let spawnLock: AsyncLock
private let logger: Logger?
init(
client: CloudHypervisor.Client,
workDir: URL,
virtiofsdBinary: URL?,
allocator: any AddressAllocator<Character>,
initialMounts: [String: [AttachedFilesystem]],
logger: Logger?
) {
self.client = client
self.workDir = workDir
self.virtiofsdBinaryOverride = virtiofsdBinary
self.allocator = allocator
self._mounts = Mutex(initialMounts)
self._records = Mutex([:])
self._tags = Mutex([:])
self.spawnLock = AsyncLock()
self.logger = logger
}
// MARK: - Read accessors
var mounts: [String: [AttachedFilesystem]] {
_mounts.withLock { $0 }
}
func withMountRegistry<T: Sendable>(
_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T
) rethrows -> T {
try _mounts.withLock(body)
}
// MARK: - HotplugProvider conformance
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem {
guard case .virtioblk = block.runtimeOptions else {
throw ContainerizationError(.invalidArgument, message: "hotplug requires a virtio-blk mount")
}
let letter = try allocator.allocate()
let chId = "blk-\(id)-\(letter)"
let disk = CloudHypervisor.DiskConfig(
path: block.source,
readonly: block.options.contains("ro"),
id: chId,
imageType: .raw
)
let pci: CloudHypervisor.PciDeviceInfo
do {
pci = try await chCall { try await self.client.vmAddDisk(disk) }
} catch {
try? allocator.release(letter)
throw error
}
let attached = AttachedFilesystem(
type: block.type,
source: "/dev/vd\(letter)",
destination: block.destination,
options: block.options
)
_records.withLock {
$0[id, default: []].append(HotplugRecord(chDeviceId: pci.id, kind: .block(letter: letter)))
}
return attached
}
func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws {
var attached: [AttachedFilesystem] = [rootfs]
for mount in additionalMounts {
attached.append(try AttachedFilesystem(mount: mount, allocator: allocator))
}
_mounts.withLock {
$0[id, default: []].append(contentsOf: attached)
}
}
func releaseHotplug(id: String) async throws {
let popped: [HotplugRecord] = _records.withLock { records in
let all = records[id] ?? []
let blocks = all.filter { record in
if case .block = record.kind { return true }
return false
}
let remaining = all.filter { record in
if case .block = record.kind { return false }
return true
}
if remaining.isEmpty {
records.removeValue(forKey: id)
} else {
records[id] = remaining
}
return blocks
}
for rec in popped {
do {
try await chCall { try await self.client.vmRemoveDevice(id: rec.chDeviceId) }
} catch {
logger?.warning("vmRemoveDevice failed for \(rec.chDeviceId): \(error)")
}
if case .block(let letter) = rec.kind {
try? allocator.release(letter)
}
}
// Drop block-derived AttachedFilesystem entries for `id`. Block entries
// are the ones whose source was rewritten to "/dev/vd<letter>" by
// `hotplug(_:)` (or by AttachedFilesystem(mount:allocator:) for an
// additionalMount of type virtio-blk).
_mounts.withLock { state in
guard var perID = state[id] else { return }
perID.removeAll { $0.source.hasPrefix("/dev/vd") }
if perID.isEmpty {
state.removeValue(forKey: id)
} else {
state[id] = perID
}
}
}
func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws {
let virtiofs = mounts.filter {
if case .virtiofs = $0.runtimeOptions { return true }
return false
}
guard !virtiofs.isEmpty else { return }
// Group by tag (source-hash). Multiple Mounts to the same source dir
// share a tag and a single virtiofsd.
var byTag: [String: [Mount]] = [:]
for mount in virtiofs {
let tag = try hashFilePath(path: mount.source)
byTag[tag, default: []].append(mount)
}
for (tag, group) in byTag {
// Hold spawnLock across the existence check and the spawn /
// _tags write so two concurrent calls for the same tag can't
// both decide alreadyRunning=false and double-spawn virtiofsd
// (the second write would clobber the first in `_tags`,
// orphaning that process).
try await spawnLock.withLock { _ in
// Build per-container AttachedFilesystem entries up front.
// These depend only on Mount + allocator and don't need the
// chDeviceId, so surfacing any error here keeps the
// transactional shape: nothing irreversible has happened
// yet, no virtiofsd has spawned, no _tags entry written.
var attached: [AttachedFilesystem] = []
for mount in group {
attached.append(try AttachedFilesystem(mount: mount, allocator: self.allocator))
}
let chDeviceId: String
// Refcount-bump path. If a virtiofsd already serves this
// tag, increment refcount and use the cached deviceId.
let cachedDeviceId: String? = self._tags.withLock { tags in
if var state = tags[tag] {
state.refcount += 1
tags[tag] = state
return state.chDeviceId
}
return nil
}
if let cached = cachedDeviceId {
chDeviceId = cached
} else {
// First-spawn path. Walk: spawn → vmAddFs → commit _tags,
// with rollback at every step so a partial failure can't
// leave a virtiofsd running unrecorded.
let socket = chVirtiofsSocketURL(workDir: self.workDir, tag: tag)
let readonly = group.allSatisfy { $0.options.contains("ro") }
guard let source = group.first?.source else { return }
let virtiofsdBinary = try CHVirtualMachineManager.resolveBinary(
self.virtiofsdBinaryOverride,
name: "virtiofsd"
)
let process = VirtiofsdProcess(
config: .init(
binary: virtiofsdBinary,
socketPath: socket,
sharedDir: URL(fileURLWithPath: source),
readonly: readonly
),
logger: self.logger
)
try await process.start()
let fsConfig = CloudHypervisor.FsConfig(
tag: tag,
socket: socket.path,
id: "fs-\(tag)"
)
let pci: CloudHypervisor.PciDeviceInfo
do {
pci = try await chCall { try await self.client.vmAddFs(fsConfig) }
} catch {
await process.terminate(graceSeconds: 5)
try? FileManager.default.removeItem(at: socket)
throw error
}
self._tags.withLock {
$0[tag] = VirtiofsdTagState(process: process, refcount: 1, chDeviceId: pci.id)
}
chDeviceId = pci.id
}
// Bookkeeping. Both writes are non-throwing closures, and
// `attached` was built up front, so once we reach here
// nothing can fail between the refcount/spawn commit above
// and the per-container record below — the orphan window
// (tag committed, record missing) is closed.
self._records.withLock {
$0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag)))
}
self._mounts.withLock {
$0[id, default: []].append(contentsOf: attached)
}
}
}
}
func releaseVirtioFS(id: String) async throws {
let popped: [HotplugRecord] = _records.withLock { records in
let all = records[id] ?? []
let fs = all.filter { record in
if case .virtiofs = record.kind { return true }
return false
}
let remaining = all.filter { record in
if case .virtiofs = record.kind { return false }
return true
}
if remaining.isEmpty {
records.removeValue(forKey: id)
} else {
records[id] = remaining
}
return fs
}
var processesToStop: [(VirtiofsdProcess, String, String)] = [] // (process, tag, chDeviceId)
for rec in popped {
guard case .virtiofs(let tag) = rec.kind else { continue }
_tags.withLock { tags in
guard var state = tags[tag] else { return }
state.refcount -= 1
if state.refcount <= 0 {
tags.removeValue(forKey: tag)
processesToStop.append((state.process, tag, state.chDeviceId))
} else {
tags[tag] = state
}
}
}
for (process, tag, chDeviceId) in processesToStop {
do {
try await chCall { try await self.client.vmRemoveDevice(id: chDeviceId) }
} catch {
logger?.warning("vmRemoveDevice failed for \(chDeviceId): \(error)")
}
await process.terminate(graceSeconds: 5)
let socket = chVirtiofsSocketURL(workDir: workDir, tag: tag)
try? FileManager.default.removeItem(at: socket)
}
// Drop virtiofs AttachedFilesystem entries for `id`. AttachedFilesystem
// sets `type = mount.type` which for a `.virtiofs` mount is "virtiofs".
_mounts.withLock { state in
guard var perID = state[id] else { return }
perID.removeAll { $0.type == "virtiofs" }
if perID.isEmpty {
state.removeValue(forKey: id)
} else {
state[id] = perID
}
}
}
// MARK: - Boot-time + shutdown hooks (used by CHVirtualMachineInstance)
/// Record a virtiofsd that was started as part of `start()`'s initial
/// `VmConfig.fs` (rather than a runtime `vm.add-fs`). The `chDeviceId`
/// is the user-supplied `FsConfig.id` (which `vm.remove-device` keys on).
/// `ownerIds` are the container ids that count toward this tag's refcount;
/// each gets a `HotplugRecord` so `releaseVirtioFS(id:)` walks them
/// uniformly.
func recordBootTimeVirtiofs(
tag: String,
process: VirtiofsdProcess,
chDeviceId: String,
ownerIds: [String]
) {
_tags.withLock {
$0[tag] = VirtiofsdTagState(process: process, refcount: ownerIds.count, chDeviceId: chDeviceId)
}
_records.withLock { records in
for id in ownerIds {
records[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag)))
}
}
}
/// Called from `CHVirtualMachineInstance.stop()` to terminate any
/// virtiofsd subprocesses still alive. The CH side teardown is handled by
/// `chProcess.terminate()`.
func shutdown() async {
let processes = _tags.withLock { tags -> [VirtiofsdProcess] in
let all = tags.values.map(\.process)
tags.removeAll()
return all
}
_records.withLock { $0.removeAll() }
for process in processes {
await process.terminate(graceSeconds: 5)
}
}
}
// MARK: - Error translation
/// Wraps a closure that may throw `CloudHypervisor.Error`, translating it into
/// `ContainerizationError` per spec §6 so callers of the public API only see
/// `ContainerizationError`.
func chCall<T: Sendable>(_ block: @Sendable () async throws -> T) async throws -> T {
do {
return try await block()
} catch let error as CloudHypervisor.Error {
switch error {
case .http(let status, let body):
let bodyStr = String(data: body, encoding: .utf8) ?? "<non-utf8 body>"
if status == .notFound {
throw ContainerizationError(.notFound, message: "cloud-hypervisor 404: \(bodyStr)")
}
if status == .badRequest {
throw ContainerizationError(.invalidArgument, message: "cloud-hypervisor 400: \(bodyStr)")
}
throw ContainerizationError(
.internalError,
message: "cloud-hypervisor HTTP \(status.code): \(bodyStr)"
)
case .transport(let underlying):
throw ContainerizationError(.internalError, message: "cloud-hypervisor transport error", cause: underlying)
case .decoding(let underlying, _):
throw ContainerizationError(.internalError, message: "cloud-hypervisor response decode error", cause: underlying)
case .invalidSocketPath(let path):
throw ContainerizationError(.invalidArgument, message: "invalid cloud-hypervisor socket path: \(path)")
}
}
}
#endif