//===----------------------------------------------------------------------===// // 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 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, 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( _ 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" 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(_ 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) ?? "" 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