Skip to content
Merged
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
9 changes: 9 additions & 0 deletions Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"] : []
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 change: 1 addition & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
46 changes: 46 additions & 0 deletions Tests/ContainerizationTests/MountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading