diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 8ad0014f8..abc84036e 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -114,6 +114,8 @@ public final class LinuxPod: Sendable { 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) + /// An in-memory (tmpfs) volume mounted inside the guest. + case tmpfs(sizeBytes: UInt64? = nil) } /// The logical name of this volume. Containers reference this name @@ -151,6 +153,13 @@ public final class LinuxPod: Sendable { destination: LinuxPod.guestVolumePath(name), options: readOnly ? ["ro"] : [] ) + case .tmpfs(let sizeBytes): + return Mount.any( + type: "tmpfs", + source: "tmpfs", + destination: LinuxPod.guestVolumePath(name), + options: sizeBytes.map { ["size=\($0)"] } ?? [] + ) } } } @@ -741,7 +750,7 @@ extension LinuxPod { type: volume.format, source: attachment.source, destination: guestPath, - options: [] + options: attachment.options )) } diff --git a/Sources/Integration/PodVolumeTests.swift b/Sources/Integration/PodVolumeTests.swift index aa029ba84..d799edc42 100644 --- a/Sources/Integration/PodVolumeTests.swift +++ b/Sources/Integration/PodVolumeTests.swift @@ -853,5 +853,97 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "disk image /second.txt: expected 'more-content', got '\(secondContent)'") } } + + /// Attach an in-memory tmpfs pod volume shared across two containers: one + /// writes a file and verifies the mount type/size, the other reads the file + /// back from a different mount path to prove it is the same backing store. + func testPodSharedTmpfsVolume() async throws { + let id = "test-pod-shared-tmpfs-volume" + let bs = try await bootstrap(id) + + 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 = 1 + config.memoryInBytes = 512.mib() + config.bootLog = bs.bootLog + config.volumes = [ + .init( + name: "mytmpfs", + source: .tmpfs(sizeBytes: 128.mib()), + format: "tmpfs" + ) + ] + } + + // Container 1: writes a file to the shared tmpfs volume and verifies mount type. + let writerBuffer = BufferWriter() + try await pod.addContainer("writer", rootfs: rootfs1) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "echo bar > /my-tmpfs-mount/foo && grep /my-tmpfs-mount /proc/mounts", + ] + config.process.stdout = writerBuffer + config.mounts.append(.sharedMount(name: "mytmpfs", destination: "/my-tmpfs-mount")) + } + + // Container 2: reads the file the writer produced, mounted at a different + // path to prove it is the same in-memory backing store. + let readerBuffer = BufferWriter() + try await pod.addContainer("reader", rootfs: rootfs2) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "cat /shared-tmpfs/foo && grep /shared-tmpfs /proc/mounts", + ] + config.process.stdout = readerBuffer + config.mounts.append(.sharedMount(name: "mytmpfs", destination: "/shared-tmpfs")) + } + + do { + try await pod.create() + + // Run sequentially so the reader observes the writer's file. + 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("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 the writer mounted a tmpfs of the expected size. + let writerOut = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard writerOut.contains("tmpfs /my-tmpfs-mount tmpfs") else { + throw IntegrationError.assert(msg: "pod volume /my-tmpfs-mount is not backed by tmpfs: \(writerOut)") + } + // tmpfs reports its size in /proc/mounts in kibibytes with a trailing 'k'. + let expectedSizeKiB: UInt64 = 128 * 1024 + guard writerOut.contains("size=\(expectedSizeKiB)k") else { + throw IntegrationError.assert(msg: "tmpfs mount is not of expected size (\(expectedSizeKiB)k): \(writerOut)") + } + + // Verify the reader saw the writer's file on the shared tmpfs at its own path. + let readerOut = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let readerLines = readerOut.components(separatedBy: "\n") + guard readerLines.count >= 2 else { + throw IntegrationError.assert(msg: "expected at least 2 lines from reader, got: \(readerOut)") + } + guard readerLines[0] == "bar" else { + throw IntegrationError.assert(msg: "reader: expected 'bar', got '\(readerLines[0])'") + } + guard readerOut.contains("tmpfs /shared-tmpfs tmpfs") else { + throw IntegrationError.assert(msg: "reader mount /shared-tmpfs is not backed by tmpfs: \(readerOut)") + } + } } #endif diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 440384ea0..87d0f348e 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -617,6 +617,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod NBD volume identity", testPodNBDVolumeIdentity), Test("pod filesystem operation", testPodFilesystemOperation), Test("pod shared disk image volume", testPodSharedDiskImageVolume), + Test("pod shared tmpfs volume", testPodSharedTmpfsVolume), ] + macOS26Tests() let tests: [Test] = crossPlatformTests + macOSOnlyTests #else diff --git a/Tests/ContainerizationTests/MountTests.swift b/Tests/ContainerizationTests/MountTests.swift index 231dcc63d..33cf175df 100644 --- a/Tests/ContainerizationTests/MountTests.swift +++ b/Tests/ContainerizationTests/MountTests.swift @@ -311,6 +311,31 @@ struct PodVolumeTests { #expect(mount.isBlock) } + @Test func podVolumeTmpfsToMountConvertsCorrectly() { + let volume = LinuxPod.PodVolume( + name: "mem", + source: .tmpfs(sizeBytes: 64 * 1024 * 1024), + format: "tmpfs" + ) + let mount = volume.toMount() + #expect(mount.type == "tmpfs") + #expect(mount.source == "tmpfs") + #expect(mount.destination == "/run/volumes/mem") + #expect(mount.options == ["size=\(64 * 1024 * 1024)"]) + } + + @Test func podVolumeTmpfsWithoutSizeHasNoOptions() { + let volume = LinuxPod.PodVolume( + name: "mem", + source: .tmpfs(), + format: "tmpfs" + ) + let mount = volume.toMount() + #expect(mount.type == "tmpfs") + #expect(mount.options.isEmpty) + #expect(!mount.isBlock) + } + @Test func sharedMountCreation() { let mount = Mount.sharedMount( name: "shared-data",