Skip to content

Commit 44bec8b

Browse files
authored
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>
1 parent d992a19 commit 44bec8b

4 files changed

Lines changed: 189 additions & 7 deletions

File tree

Sources/Containerization/LinuxPod.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ public final class LinuxPod: Sendable {
102102
public enum Source: Sendable {
103103
/// A network block device (NBD) volume.
104104
case nbd(url: URL, timeout: TimeInterval? = nil, readOnly: Bool = false)
105+
/// A disk-image file on the host, attached as a virtio-block device.
106+
case diskImage(path: URL, readOnly: Bool = false)
105107
}
106108

107109
/// The logical name of this volume. Containers reference this name
@@ -132,6 +134,13 @@ public final class LinuxPod: Sendable {
132134
options: readOnly ? ["ro"] : [],
133135
runtimeOptions: runtimeOptions
134136
)
137+
case .diskImage(let path, let readOnly):
138+
return Mount.block(
139+
format: self.format,
140+
source: path.absolutePath(),
141+
destination: LinuxPod.guestVolumePath(name),
142+
options: readOnly ? ["ro"] : []
143+
)
135144
}
136145
}
137146
}
Lines changed: 133 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import Logging
2424
import SystemPackage
2525

2626
extension IntegrationSuite {
27-
private func cloneRootfsForNBD(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount {
27+
private func cloneRootfsForContainer(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount {
2828
let clonePath = Self.testDir.appending(component: "\(testID)-\(containerID).ext4").absolutePath()
2929
try? FileManager.default.removeItem(atPath: clonePath)
3030
return try rootfs.clone(to: clonePath)
@@ -295,8 +295,8 @@ extension IntegrationSuite {
295295
let (server, diskURL) = try createNBDServer(testID: id, name: "shared")
296296
defer { server.stop() }
297297

298-
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer")
299-
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader")
298+
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
299+
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
300300

301301
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
302302
config.cpus = 4
@@ -511,8 +511,8 @@ extension IntegrationSuite {
511511
let (server, _) = try createNBDServer(testID: id, name: "persistent")
512512
defer { server.stop() }
513513

514-
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer")
515-
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader")
514+
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
515+
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
516516

517517
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
518518
config.cpus = 4
@@ -578,8 +578,8 @@ extension IntegrationSuite {
578578
let (server, _) = try createNBDServer(testID: id, name: "shared")
579579
defer { server.stop() }
580580

581-
let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c1")
582-
let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c2")
581+
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c1")
582+
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c2")
583583

584584
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
585585
config.cpus = 4
@@ -726,4 +726,130 @@ extension IntegrationSuite {
726726
}
727727
}
728728
}
729+
730+
/// Attach an empty EXT4 disk-image file as a pod volume and have
731+
/// multiple containers read from and write to the shared mount.
732+
func testPodSharedDiskImageVolume() async throws {
733+
let id = "test-pod-shared-disk-image-volume"
734+
let bs = try await bootstrap(id)
735+
736+
// Create an empty EXT4 disk image to back the shared volume.
737+
let diskURL = try createEXT4DiskImage(testID: id, name: "shared")
738+
739+
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
740+
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "appender")
741+
let rootfs3 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
742+
743+
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
744+
config.cpus = 1
745+
config.memoryInBytes = 512.mib()
746+
config.bootLog = bs.bootLog
747+
config.volumes = [
748+
.init(
749+
name: "shared-data",
750+
source: .diskImage(path: diskURL),
751+
format: "ext4"
752+
)
753+
]
754+
}
755+
756+
// Container 1: writes a file to the shared volume and verifies mount type.
757+
let writerBuffer = BufferWriter()
758+
try await pod.addContainer("writer", rootfs: rootfs1) { config in
759+
config.process.arguments = [
760+
"/bin/sh", "-c",
761+
"echo shared-content > /data/shared.txt && grep /data /proc/mounts",
762+
]
763+
config.process.stdout = writerBuffer
764+
config.mounts.append(.sharedMount(name: "shared-data", destination: "/data"))
765+
}
766+
767+
// Container 2: reads what the writer produced and writes a second file,
768+
// mounted at a different path to prove it's the same backing store.
769+
let appenderBuffer = BufferWriter()
770+
try await pod.addContainer("appender", rootfs: rootfs2) { config in
771+
config.process.arguments = [
772+
"/bin/sh", "-c",
773+
"cat /vol/shared.txt && echo more-content > /vol/second.txt",
774+
]
775+
config.process.stdout = appenderBuffer
776+
config.mounts.append(.sharedMount(name: "shared-data", destination: "/vol"))
777+
}
778+
779+
// Container 3: reads both files written by the previous containers.
780+
let readerBuffer = BufferWriter()
781+
try await pod.addContainer("reader", rootfs: rootfs3) { config in
782+
config.process.arguments = [
783+
"/bin/sh", "-c",
784+
"cat /shared/shared.txt && cat /shared/second.txt && grep /shared /proc/mounts",
785+
]
786+
config.process.stdout = readerBuffer
787+
config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared"))
788+
}
789+
790+
do {
791+
try await pod.create()
792+
793+
// Run the containers sequentially so reads see prior writes.
794+
try await pod.startContainer("writer")
795+
let writerStatus = try await pod.waitContainer("writer")
796+
guard writerStatus.exitCode == 0 else {
797+
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
798+
}
799+
800+
try await pod.startContainer("appender")
801+
let appenderStatus = try await pod.waitContainer("appender")
802+
guard appenderStatus.exitCode == 0 else {
803+
throw IntegrationError.assert(msg: "appender exited with status \(appenderStatus)")
804+
}
805+
806+
try await pod.startContainer("reader")
807+
let readerStatus = try await pod.waitContainer("reader")
808+
guard readerStatus.exitCode == 0 else {
809+
throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)")
810+
}
811+
try await pod.stop()
812+
} catch {
813+
try? await pod.stop()
814+
throw error
815+
}
816+
817+
// Verify writer mounted a virtio block device at /data.
818+
let writerOutput = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
819+
let writerLines = writerOutput.components(separatedBy: "\n")
820+
guard !writerLines.isEmpty else {
821+
throw IntegrationError.assert(msg: "writer produced no output")
822+
}
823+
try assertVirtioBlockMount(writerLines.last!, path: "/data")
824+
825+
// Verify the appender read the writer's file.
826+
let appenderOutput = String(data: appenderBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
827+
guard appenderOutput == "shared-content" else {
828+
throw IntegrationError.assert(msg: "appender: expected 'shared-content', got '\(appenderOutput)'")
829+
}
830+
831+
// Verify the reader saw both files and a virtio block mount.
832+
let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
833+
let readerLines = readerOutput.components(separatedBy: "\n")
834+
guard readerLines.count >= 3 else {
835+
throw IntegrationError.assert(msg: "expected at least 3 lines from reader, got: \(readerOutput)")
836+
}
837+
guard readerLines[0] == "shared-content" else {
838+
throw IntegrationError.assert(msg: "reader: expected 'shared-content', got '\(readerLines[0])'")
839+
}
840+
guard readerLines[1] == "more-content" else {
841+
throw IntegrationError.assert(msg: "reader: expected 'more-content', got '\(readerLines[1])'")
842+
}
843+
try assertVirtioBlockMount(readerLines[2], path: "/shared")
844+
845+
// Verify both writes landed on the host-side EXT4 disk image.
846+
let firstContent = try readFileFromDiskImage(diskURL, path: "/shared.txt")
847+
guard firstContent == "shared-content" else {
848+
throw IntegrationError.assert(msg: "disk image /shared.txt: expected 'shared-content', got '\(firstContent)'")
849+
}
850+
let secondContent = try readFileFromDiskImage(diskURL, path: "/second.txt")
851+
guard secondContent == "more-content" else {
852+
throw IntegrationError.assert(msg: "disk image /second.txt: expected 'more-content', got '\(secondContent)'")
853+
}
854+
}
729855
}

Sources/Integration/Suite.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,7 @@ struct IntegrationSuite: AsyncParsableCommand {
465465
Test("pod invalid volume reference", testPodInvalidVolumeReference),
466466
Test("pod duplicate volume name", testPodDuplicateVolumeName),
467467
Test("pod filesystem operation", testPodFilesystemOperation),
468+
Test("pod shared disk image volume", testPodSharedDiskImageVolume),
468469
] + macOS26Tests()
469470

470471
let filteredTests: [Test]

Tests/ContainerizationTests/MountTests.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,52 @@ struct PodVolumeTests {
265265
#expect(mount.type == "xfs")
266266
}
267267

268+
@Test func podVolumeDiskImageSourceCreation() {
269+
let volume = LinuxPod.PodVolume(
270+
name: "disk-data",
271+
source: .diskImage(path: URL(fileURLWithPath: "/tmp/disk.ext4")),
272+
format: "ext4"
273+
)
274+
275+
#expect(volume.name == "disk-data")
276+
#expect(volume.format == "ext4")
277+
if case .diskImage(let path, let readOnly) = volume.source {
278+
#expect(path.path == "/tmp/disk.ext4")
279+
#expect(readOnly == false)
280+
} else {
281+
Issue.record("Expected .diskImage source")
282+
}
283+
}
284+
285+
@Test func podVolumeDiskImageToMountConvertsCorrectly() {
286+
let volume = LinuxPod.PodVolume(
287+
name: "my-disk",
288+
source: .diskImage(path: URL(fileURLWithPath: "/tmp/my-disk.ext4")),
289+
format: "ext4"
290+
)
291+
292+
let mount = volume.toMount()
293+
294+
// The mount source must be the raw filesystem path, not a file:// URL.
295+
#expect(mount.source == "/tmp/my-disk.ext4")
296+
#expect(mount.destination == "/run/volumes/my-disk")
297+
#expect(mount.type == "ext4")
298+
#expect(mount.isBlock)
299+
}
300+
301+
@Test func podVolumeDiskImageReadOnlySetsOptions() {
302+
let volume = LinuxPod.PodVolume(
303+
name: "ro-disk",
304+
source: .diskImage(path: URL(fileURLWithPath: "/tmp/ro-disk.ext4"), readOnly: true),
305+
format: "ext4"
306+
)
307+
308+
let mount = volume.toMount()
309+
310+
#expect(mount.options.contains("ro"))
311+
#expect(mount.isBlock)
312+
}
313+
268314
@Test func sharedMountCreation() {
269315
let mount = Mount.sharedMount(
270316
name: "shared-data",

0 commit comments

Comments
 (0)