From 757c0f195cb57c11606e95bec4a4b9710af437c6 Mon Sep 17 00:00:00 2001 From: Aaron Paterson Date: Mon, 3 Aug 2026 02:12:52 +0000 Subject: [PATCH] Support attaching a host block device to a guest Virtualization offers three storage attachments and the mount path reached two of them, so a block device of the host's had no way through to a guest. One now goes as the device it is, from an extension alongside the disk image and network block device ones, picked by asking the source what it is rather than by a naming convention the caller has to keep. This lets whatever backs the device on the host back it in the guest, which a disk image has no way to say: a partition, a whole disk, or a device the host holds in memory, whose pages it compresses and pages out under contention as it would any others. Integration coverage attaches an ext4 image as a block device, writes to it from the guest, and reads what landed back out of the image once the host has it again. The read only case confirms the write is refused, and a memory backed device carries a raw write with no filesystem in the way. --- Sources/Containerization/Mount.swift | 76 ++++++++++++ Sources/Integration/PodVolumeTests.swift | 151 +++++++++++++++++++++++ Sources/Integration/Suite.swift | 88 +++++++++++++ 3 files changed, 315 insertions(+) diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index b516deae8..c376f64d9 100644 --- a/Sources/Containerization/Mount.swift +++ b/Sources/Containerization/Mount.swift @@ -285,6 +285,7 @@ extension Mount { private enum StorageAttachmentType { case diskImage case networkBlockDevice + case blockDevice } private var storageAttachmentType: StorageAttachmentType { @@ -292,6 +293,13 @@ extension Mount { if nbdSchemes.contains(where: { self.source.hasPrefix($0) }) { return .networkBlockDevice } + // A block device reaches the guest as the device it already is, where an + // image is a file the framework opens and presents as one. Asking the + // source what it is leaves the caller no naming convention to observe. + var info = stat() + if stat(self.source, &info) == 0, (info.st_mode & S_IFMT) == S_IFBLK { + return .blockDevice + } return .diskImage } @@ -304,6 +312,8 @@ extension Mount { device = try VZNetworkBlockDeviceStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options) case .diskImage: device = try VZDiskImageStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options) + case .blockDevice: + device = try VZDiskBlockDeviceStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options) } let attachment = VZVirtioBlockDeviceConfiguration(attachment: device) config.storageDevices.append(attachment) @@ -435,6 +445,72 @@ extension VZNetworkBlockDeviceStorageDeviceAttachment { } } +extension VZDiskBlockDeviceStorageDeviceAttachment { + /// Attach a block device of the host's to the guest. + /// + /// The guest is given the device itself, so whatever backs it on the host + /// backs it in the guest: a disk, a partition, or a memory backed device + /// whose pages the host is free to compress and page out as it would any + /// other. Nothing here is written through a file of the host's own. + /// + /// A device holding a filesystem is destroyable by the guest in ways that + /// do not recover, so the caller chooses what it hands over. + /// https://developer.apple.com/documentation/virtualization/vzdiskblockdevicestoragedeviceattachment + static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZDiskBlockDeviceStorageDeviceAttachment { + var synchronizationMode: VZDiskSynchronizationMode = .full + + for option in options { + let split = option.split(separator: "=") + if split.count != 2 { + continue + } + + let key = String(split[0]) + let value = String(split[1]) + + switch key { + case "vzSynchronizationMode": + switch value { + case "full": + synchronizationMode = .full + case "none": + synchronizationMode = .none + default: + throw ContainerizationError( + .invalidArgument, + message: "unknown vzSynchronizationMode value for block device: \(value)" + ) + } + default: + throw ContainerizationError( + .invalidArgument, + message: "unknown vmm option encountered: \(key)" + ) + } + } + + // The attachment keeps the handle, and the framework wants it open when + // the machine starts. A device meant to be read is opened to be read, + // which is what the framework asks of a caller that sets `readOnly`. + let handle = + mount.readonly + ? FileHandle(forReadingAtPath: mount.source) + : FileHandle(forUpdatingAtPath: mount.source) + guard let handle else { + throw ContainerizationError( + .invalidArgument, + message: "unable to open block device: \(mount.source)" + ) + } + + return try VZDiskBlockDeviceStorageDeviceAttachment( + fileHandle: handle, + readOnly: mount.readonly, + synchronizationMode: synchronizationMode + ) + } +} + #endif extension Mount { diff --git a/Sources/Integration/PodVolumeTests.swift b/Sources/Integration/PodVolumeTests.swift index d799edc42..52be09258 100644 --- a/Sources/Integration/PodVolumeTests.swift +++ b/Sources/Integration/PodVolumeTests.swift @@ -19,6 +19,7 @@ import ContainerizationArchive import ContainerizationEXT4 import ContainerizationError import ContainerizationOCI +import ContainerizationOS import Foundation import Logging import SystemPackage @@ -78,6 +79,68 @@ extension IntegrationSuite { } } + func testContainerBlockDeviceMount() async throws { + let id = "test-container-block-device-mount" + let bs = try await bootstrap(id) + + let diskURL = try createEXT4DiskImage(testID: id, name: "vol") + let device = try Self.attachBlockDevice(imagePath: diskURL.absolutePath()) + var attached = true + defer { + if attached { + Self.detachBlockDevice(device) + } + } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + Mount.block( + format: "ext4", + source: device, + destination: "/data" + )) + config.process.arguments = [ + "/bin/sh", "-c", + "echo hello > /data/test.txt && cat /data/test.txt && grep /data /proc/mounts", + ] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container exited with status \(status)") + } + + let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let lines = output.components(separatedBy: "\n") + + guard lines.count >= 2 else { + throw IntegrationError.assert(msg: "expected at least 2 lines of output, got: \(output)") + } + + guard lines[0] == "hello" else { + throw IntegrationError.assert(msg: "expected 'hello', got '\(lines[0])'") + } + + try assertVirtioBlockMount(lines[1], path: "/data") + + // The guest wrote to the device, and the device is the image: what the + // container put there is in the file once the host has it back. + Self.detachBlockDevice(device) + attached = false + let diskContent = try readFileFromDiskImage(diskURL, path: "/test.txt") + guard diskContent == "hello" else { + throw IntegrationError.assert(msg: "block device image: expected 'hello', got '\(diskContent)'") + } + } + func testContainerNBDMount() async throws { let id = "test-container-nbd-mount" let bs = try await bootstrap(id) @@ -131,6 +194,94 @@ extension IntegrationSuite { } } + func testContainerBlockDeviceReadOnly() async throws { + let id = "test-container-block-device-readonly" + let bs = try await bootstrap(id) + + let diskURL = try createEXT4DiskImage(testID: id, name: "ro-vol") + let device = try Self.attachBlockDevice(imagePath: diskURL.absolutePath()) + defer { Self.detachBlockDevice(device) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + Mount.block( + format: "ext4", + source: device, + destination: "/data", + options: ["ro"] + )) + // Verify virtio block mount, then attempt a write that should fail. + config.process.arguments = [ + "/bin/sh", "-c", + "grep /data /proc/mounts; echo test > /data/fail.txt 2>&1; echo exit=$?", + ] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + _ = try await container.wait() + try await container.stop() + + let output = String(data: buffer.data, encoding: .utf8) ?? "" + let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n") + + guard !lines.isEmpty else { + throw IntegrationError.assert(msg: "expected output, got nothing") + } + + try assertVirtioBlockMount(lines[0], path: "/data") + + guard !output.contains("exit=0") else { + throw IntegrationError.assert(msg: "write to a read only block device succeeded: \(output)") + } + } + + func testContainerMemoryBlockDevice() async throws { + let id = "test-container-memory-block-device" + let bs = try await bootstrap(id) + + let device = try Self.attachMemoryBlockDevice(size: 64.mib()) + defer { Self.detachBlockDevice(device) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // Nothing has written a filesystem to it, so the guest is given the + // device itself rather than something mounted out of it. + config.mounts.append( + Mount.block( + format: "none", + source: device, + destination: "/dev/memdisk", + options: ["bind"] + )) + config.process.arguments = [ + "/bin/sh", "-c", + "test -b /dev/memdisk && printf 'memory-block-works' | dd of=/dev/memdisk bs=512 count=1 conv=sync 2>/dev/null && dd if=/dev/memdisk bs=1 count=18 2>/dev/null", + ] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container exited with status \(status)") + } + + let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard output == "memory-block-works" else { + throw IntegrationError.assert(msg: "expected 'memory-block-works', got '\(output)'") + } + } + func testContainerNBDReadOnly() async throws { let id = "test-container-nbd-readonly" let bs = try await bootstrap(id) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9dae84351..58f1c1b1c 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -195,6 +195,91 @@ struct IntegrationSuite: AsyncParsableCommand { static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + #if os(macOS) + /// Attach something to the host as a block device and hand back its path. + /// + /// A block device attachment takes a device, so a test needs one of its own + /// to give. What `hdiutil` attaches belongs to whoever asked for it, which + /// is what keeps this off root. + private static func attachDevice(describing what: String, arguments: [String]) throws -> String { + let pipe = Pipe() + let errPipe = Pipe() + defer { + try? pipe.fileHandleForReading.close() + try? errPipe.fileHandleForReading.close() + } + // The plist output is the one hdiutil defines, where the plain output is + // a table whose columns depend on what it found. Its stderr is what says + // why something it will not take is something it will not take. + var cmd = Command("/usr/bin/hdiutil", arguments: ["attach", "-nomount", "-plist"] + arguments) + cmd.stdout = pipe.fileHandleForWriting + cmd.stderr = errPipe.fileHandleForWriting + try cmd.start() + try? pipe.fileHandleForWriting.close() + try? errPipe.fileHandleForWriting.close() + let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data() + let errData = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() + let exit = try cmd.wait() + guard exit == 0 else { + let reason = + String(data: errData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + throw IntegrationError.assert(msg: "hdiutil attach of \(what) exited \(exit): \(reason)") + } + + let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) + guard let root = plist as? [String: Any], + let entities = root["system-entities"] as? [[String: Any]] + else { + throw IntegrationError.assert(msg: "hdiutil attach of \(what) described no entities") + } + let devices = entities.compactMap { $0["dev-entry"] as? String } + // Whatever it found sits under the whole device, and that is the one to + // hand over: the guest is the one deciding what the contents mean. + guard let device = devices.min(by: { $0.count < $1.count }) else { + throw IntegrationError.assert(msg: "hdiutil attach of \(what) named no device") + } + return device + } + + /// Attach a file to the host as a block device. + /// + /// An image holding a filesystem of the guest's is one hdiutil reads far + /// enough to decline, so naming the class tells it to carry the bytes and + /// leave the reading to whoever mounts them. + static func attachBlockDevice(imagePath: String) throws -> String { + try Self.attachDevice( + describing: imagePath, + arguments: ["-imagekey", "diskimage-class=CRawDiskImage", imagePath]) + } + + /// Attach a block device the host keeps in memory. + /// + /// The pages behind it are the host's ordinary memory: taken as they are + /// written rather than reserved up front, and left for the host to compress + /// and page out under contention as it would any others. + static func attachMemoryBlockDevice(size: UInt64) throws -> String { + // A ram disk is asked for in units of 512 byte sectors. + try Self.attachDevice( + describing: "a \(size) byte ram disk", + arguments: ["ram://\(size / 512)"]) + } + + /// Give a block device back to the host. + /// + /// Called both on the way out of a test and once the guest is done with the + /// device, so it tolerates a device that is already gone. + static func detachBlockDevice(_ device: String) { + let devNull = FileHandle(forWritingAtPath: "/dev/null") + defer { try? devNull?.close() } + var cmd = Command("/usr/bin/hdiutil", arguments: ["detach", device]) + cmd.stdout = devNull + cmd.stderr = devNull + guard (try? cmd.start()) != nil else { return } + _ = try? cmd.wait() + } + #endif + func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootLog: BootLog) { let reference = "ghcr.io/linuxcontainers/alpine:3.20" let store = Self.imageStore @@ -608,6 +693,9 @@ struct IntegrationSuite: AsyncParsableCommand { Test("multiple concurrent processes with output stress", testMultipleConcurrentProcessesOutputStress), // NBD volumes (test infra is macOS-only) + Test("container block device mount", testContainerBlockDeviceMount), + Test("container block device read only", testContainerBlockDeviceReadOnly), + Test("container memory block device", testContainerMemoryBlockDevice), Test("container NBD mount", testContainerNBDMount), Test("container NBD read-only", testContainerNBDReadOnly), Test("container NBD raw block", testContainerNBDRawBlock),