diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 92c8b181..dbe4365e 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -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"] : [] + ) } } } diff --git a/Sources/Integration/NBDTests.swift b/Sources/Integration/PodVolumeTests.swift similarity index 80% rename from Sources/Integration/NBDTests.swift rename to Sources/Integration/PodVolumeTests.swift index 22783ade..358ebdaa 100644 --- a/Sources/Integration/NBDTests.swift +++ b/Sources/Integration/PodVolumeTests.swift @@ -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)'") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index ccd8869e..718ca783 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -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] diff --git a/Tests/ContainerizationTests/MountTests.swift b/Tests/ContainerizationTests/MountTests.swift index 6b64d81c..231dcc63 100644 --- a/Tests/ContainerizationTests/MountTests.swift +++ b/Tests/ContainerizationTests/MountTests.swift @@ -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",