Support disk image based pod volumes (#780)

The `PodVolume` type in `LinuxPod` only defined the `nbd` enum value -
however, disk based images are also supported and the pattern is
essentially the same

Signed-off-by: Aditya Ramani <a_ramani@apple.com>
This commit is contained in:
Aditya Ramani authored and GitHub committed 2026-06-26 10:55:36 -07:00
1 parent d992a1996d
commit 44bec8b993
4 files changed
+189 -7

No files matched your search

+9
View File
@@ -102,6 +102,8 @@ public final class LinuxPod: Sendable {
public enum Source: Sendable {
/// A network block device (NBD) volume.
case nbd(url: URL, timeout: TimeInterval? = nil, readOnly: Bool = false)
/// A disk-image file on the host, attached as a virtio-block device.
case diskImage(path: URL, readOnly: Bool = false)
}
/// The logical name of this volume. Containers reference this name
@@ -132,6 +134,13 @@ public final class LinuxPod: Sendable {
options: readOnly ? ["ro"] : [],
runtimeOptions: runtimeOptions
)
case .diskImage(let path, let readOnly):
return Mount.block(
format: self.format,
source: path.absolutePath(),
destination: LinuxPod.guestVolumePath(name),
options: readOnly ? ["ro"] : []
)
}
}
}
@@ -24,7 +24,7 @@ import Logging
import SystemPackage
extension IntegrationSuite {
private func cloneRootfsForNBD(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount {
private func cloneRootfsForContainer(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount {
let clonePath = Self.testDir.appending(component: "\(testID)-\(containerID).ext4").absolutePath()
try? FileManager.default.removeItem(atPath: clonePath)
return try rootfs.clone(to: clonePath)
@@ -295,8 +295,8 @@ extension IntegrationSuite {
let (server, diskURL) = try createNBDServer(testID: id, name: "shared")
defer { server.stop() }
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer")
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader")
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
@@ -511,8 +511,8 @@ extension IntegrationSuite {
let (server, _) = try createNBDServer(testID: id, name: "persistent")
defer { server.stop() }
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer")
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader")
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
@@ -578,8 +578,8 @@ extension IntegrationSuite {
let (server, _) = try createNBDServer(testID: id, name: "shared")
defer { server.stop() }
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c1")
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c2")
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c1")
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c2")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
@@ -726,4 +726,130 @@ extension IntegrationSuite {
}
}
}
/// Attach an empty EXT4 disk-image file as a pod volume and have
/// multiple containers read from and write to the shared mount.
func testPodSharedDiskImageVolume() async throws {
let id = "test-pod-shared-disk-image-volume"
let bs = try await bootstrap(id)
// Create an empty EXT4 disk image to back the shared volume.
let diskURL = try createEXT4DiskImage(testID: id, name: "shared")
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "appender")
let rootfs3 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 1
config.memoryInBytes = 512.mib()
config.bootLog = bs.bootLog
config.volumes = [
.init(
name: "shared-data",
source: .diskImage(path: diskURL),
format: "ext4"
)
]
}
// Container 1: writes a file to the shared volume and verifies mount type.
let writerBuffer = BufferWriter()
try await pod.addContainer("writer", rootfs: rootfs1) { config in
config.process.arguments = [
"/bin/sh", "-c",
"echo shared-content > /data/shared.txt && grep /data /proc/mounts",
]
config.process.stdout = writerBuffer
config.mounts.append(.sharedMount(name: "shared-data", destination: "/data"))
}
// Container 2: reads what the writer produced and writes a second file,
// mounted at a different path to prove it's the same backing store.
let appenderBuffer = BufferWriter()
try await pod.addContainer("appender", rootfs: rootfs2) { config in
config.process.arguments = [
"/bin/sh", "-c",
"cat /vol/shared.txt && echo more-content > /vol/second.txt",
]
config.process.stdout = appenderBuffer
config.mounts.append(.sharedMount(name: "shared-data", destination: "/vol"))
}
// Container 3: reads both files written by the previous containers.
let readerBuffer = BufferWriter()
try await pod.addContainer("reader", rootfs: rootfs3) { config in
config.process.arguments = [
"/bin/sh", "-c",
"cat /shared/shared.txt && cat /shared/second.txt && grep /shared /proc/mounts",
]
config.process.stdout = readerBuffer
config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared"))
}
do {
try await pod.create()
// Run the containers sequentially so reads see prior writes.
try await pod.startContainer("writer")
let writerStatus = try await pod.waitContainer("writer")
guard writerStatus.exitCode == 0 else {
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
}
try await pod.startContainer("appender")
let appenderStatus = try await pod.waitContainer("appender")
guard appenderStatus.exitCode == 0 else {
throw IntegrationError.assert(msg: "appender exited with status \(appenderStatus)")
}
try await pod.startContainer("reader")
let readerStatus = try await pod.waitContainer("reader")
guard readerStatus.exitCode == 0 else {
throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)")
}
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
// Verify writer mounted a virtio block device at /data.
let writerOutput = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let writerLines = writerOutput.components(separatedBy: "\n")
guard !writerLines.isEmpty else {
throw IntegrationError.assert(msg: "writer produced no output")
}
try assertVirtioBlockMount(writerLines.last!, path: "/data")
// Verify the appender read the writer's file.
let appenderOutput = String(data: appenderBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard appenderOutput == "shared-content" else {
throw IntegrationError.assert(msg: "appender: expected 'shared-content', got '\(appenderOutput)'")
}
// Verify the reader saw both files and a virtio block mount.
let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let readerLines = readerOutput.components(separatedBy: "\n")
guard readerLines.count >= 3 else {
throw IntegrationError.assert(msg: "expected at least 3 lines from reader, got: \(readerOutput)")
}
guard readerLines[0] == "shared-content" else {
throw IntegrationError.assert(msg: "reader: expected 'shared-content', got '\(readerLines[0])'")
}
guard readerLines[1] == "more-content" else {
throw IntegrationError.assert(msg: "reader: expected 'more-content', got '\(readerLines[1])'")
}
try assertVirtioBlockMount(readerLines[2], path: "/shared")
// Verify both writes landed on the host-side EXT4 disk image.
let firstContent = try readFileFromDiskImage(diskURL, path: "/shared.txt")
guard firstContent == "shared-content" else {
throw IntegrationError.assert(msg: "disk image /shared.txt: expected 'shared-content', got '\(firstContent)'")
}
let secondContent = try readFileFromDiskImage(diskURL, path: "/second.txt")
guard secondContent == "more-content" else {
throw IntegrationError.assert(msg: "disk image /second.txt: expected 'more-content', got '\(secondContent)'")
}
}
}
+1
View File
@@ -465,6 +465,7 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("pod invalid volume reference", testPodInvalidVolumeReference),
Test("pod duplicate volume name", testPodDuplicateVolumeName),
Test("pod filesystem operation", testPodFilesystemOperation),
Test("pod shared disk image volume", testPodSharedDiskImageVolume),
] + macOS26Tests()
let filteredTests: [Test]
@@ -265,6 +265,52 @@ struct PodVolumeTests {
#expect(mount.type == "xfs")
}
@Test func podVolumeDiskImageSourceCreation() {
let volume = LinuxPod.PodVolume(
name: "disk-data",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/disk.ext4")),
format: "ext4"
)
#expect(volume.name == "disk-data")
#expect(volume.format == "ext4")
if case .diskImage(let path, let readOnly) = volume.source {
#expect(path.path == "/tmp/disk.ext4")
#expect(readOnly == false)
} else {
Issue.record("Expected .diskImage source")
}
}
@Test func podVolumeDiskImageToMountConvertsCorrectly() {
let volume = LinuxPod.PodVolume(
name: "my-disk",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/my-disk.ext4")),
format: "ext4"
)
let mount = volume.toMount()
// The mount source must be the raw filesystem path, not a file:// URL.
#expect(mount.source == "/tmp/my-disk.ext4")
#expect(mount.destination == "/run/volumes/my-disk")
#expect(mount.type == "ext4")
#expect(mount.isBlock)
}
@Test func podVolumeDiskImageReadOnlySetsOptions() {
let volume = LinuxPod.PodVolume(
name: "ro-disk",
source: .diskImage(path: URL(fileURLWithPath: "/tmp/ro-disk.ext4"), readOnly: true),
format: "ext4"
)
let mount = volume.toMount()
#expect(mount.options.contains("ro"))
#expect(mount.isBlock)
}
@Test func sharedMountCreation() {
let mount = Mount.sharedMount(
name: "shared-data",