diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index b516deae..c376f64d 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 d799edc4..52be0925 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 9dae8435..58f1c1b1 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),