Skip to content
Closed
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
76 changes: 76 additions & 0 deletions Sources/Containerization/Mount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,21 @@ extension Mount {
private enum StorageAttachmentType {
case diskImage
case networkBlockDevice
case blockDevice
}

private var storageAttachmentType: StorageAttachmentType {
let nbdSchemes = ["nbd://", "nbds://", "nbd+unix://", "nbds+unix://"]
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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
151 changes: 151 additions & 0 deletions Sources/Integration/PodVolumeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ContainerizationArchive
import ContainerizationEXT4
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import SystemPackage
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down