mirror of
https://github.com/apple/container.git
synced 2026-10-24 23:58:42 +00:00
This change adds an additional unit test to test attaching a raw block device to a container to ensure raw block works without API changes. We just need to set format to none and bind mount the raw block device to the container path
730 lines
29 KiB
Swift
730 lines
29 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//===----------------------------------------------------------------------===//
|
|
|
|
import Containerization
|
|
import ContainerizationArchive
|
|
import ContainerizationEXT4
|
|
import ContainerizationError
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import Logging
|
|
import SystemPackage
|
|
|
|
extension IntegrationSuite {
|
|
private func cloneRootfsForNBD(_ 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)
|
|
}
|
|
|
|
private func createEXT4DiskImage(testID: String, name: String, size: UInt64 = 64.mib()) throws -> URL {
|
|
let diskURL = Self.testDir.appending(component: "\(testID)-\(name).ext4")
|
|
try? FileManager.default.removeItem(at: diskURL)
|
|
let formatter = try EXT4.Formatter(FilePath(diskURL.absolutePath()), minDiskSize: size)
|
|
try formatter.close()
|
|
return diskURL
|
|
}
|
|
|
|
/// Create an ext4 disk image with a file already written to it.
|
|
private func createEXT4DiskImageWithFile(
|
|
testID: String, name: String, filePath: String, content: String, size: UInt64 = 64.mib()
|
|
) throws -> URL {
|
|
let diskURL = Self.testDir.appending(component: "\(testID)-\(name).ext4")
|
|
try? FileManager.default.removeItem(at: diskURL)
|
|
let formatter = try EXT4.Formatter(FilePath(diskURL.absolutePath()), minDiskSize: size)
|
|
let data = Data(content.utf8)
|
|
let stream = InputStream(data: data)
|
|
stream.open()
|
|
defer { stream.close() }
|
|
try formatter.create(path: FilePath(filePath), mode: 0o100644, buf: stream)
|
|
try formatter.close()
|
|
return diskURL
|
|
}
|
|
|
|
private func createNBDServer(testID: String, name: String, size: UInt64 = 64.mib()) throws -> (NBDServer, URL) {
|
|
let diskURL = try createEXT4DiskImage(testID: testID, name: name, size: size)
|
|
let shortID = String(testID.hashValue, radix: 36, uppercase: false)
|
|
let socketPath = "/tmp/nbd-\(shortID)-\(name).sock"
|
|
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
|
return (server, diskURL)
|
|
}
|
|
|
|
private func readFileFromDiskImage(_ diskURL: URL, path: String) throws -> String {
|
|
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(diskURL.path))
|
|
let bytes = try reader.readFile(at: FilePath(path))
|
|
guard let content = String(bytes: bytes, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to decode file content from disk image at \(path)")
|
|
}
|
|
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func assertVirtioBlockMount(_ output: String, path: String) throws {
|
|
guard output.contains("/dev/vd") else {
|
|
throw IntegrationError.assert(msg: "expected virtio block device (/dev/vd*) for \(path), got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testContainerNBDMount() async throws {
|
|
let id = "test-container-nbd-mount"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let (server, diskURL) = try createNBDServer(testID: id, name: "vol")
|
|
defer { server.stop() }
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: server.url,
|
|
destination: "/data"
|
|
))
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"echo hello > /data/test.txt && cat /data/test.txt && grep /data /proc/mounts",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lines = output.components(separatedBy: "\n")
|
|
|
|
guard lines.count >= 2 else {
|
|
throw IntegrationError.assert(msg: "expected at least 2 lines of output, got: \(output)")
|
|
}
|
|
|
|
guard lines[0] == "hello" else {
|
|
throw IntegrationError.assert(msg: "expected 'hello', got '\(lines[0])'")
|
|
}
|
|
|
|
try assertVirtioBlockMount(lines[1], path: "/data")
|
|
|
|
// Verify the write landed on the NBD backing file.
|
|
let diskContent = try readFileFromDiskImage(diskURL, path: "/test.txt")
|
|
guard diskContent == "hello" else {
|
|
throw IntegrationError.assert(msg: "NBD backing file: expected 'hello', got '\(diskContent)'")
|
|
}
|
|
}
|
|
|
|
func testContainerNBDReadOnly() async throws {
|
|
let id = "test-container-nbd-readonly"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let (server, _) = try createNBDServer(testID: id, name: "ro-vol")
|
|
defer { server.stop() }
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: server.url,
|
|
destination: "/data",
|
|
options: ["ro"]
|
|
))
|
|
// Verify virtio block mount, then attempt a write that should fail.
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"grep /data /proc/mounts; echo test > /data/fail.txt 2>&1; echo exit=$?",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
_ = try await container.wait()
|
|
try await container.stop()
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8) ?? ""
|
|
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n")
|
|
|
|
guard !lines.isEmpty else {
|
|
throw IntegrationError.assert(msg: "expected output, got nothing")
|
|
}
|
|
|
|
// First line should show the virtio block device mount.
|
|
try assertVirtioBlockMount(lines[0], path: "/data")
|
|
|
|
// Write should have failed on a read-only mount.
|
|
guard !output.contains("exit=0") else {
|
|
throw IntegrationError.assert(msg: "write succeeded on read-only NBD mount: \(output)")
|
|
}
|
|
}
|
|
|
|
func testContainerNBDRawBlock() async throws {
|
|
let id = "test-container-nbd-raw-block"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create an unformatted disk image, no filesystem.
|
|
let diskURL = Self.testDir.appending(component: "\(id)-raw.img")
|
|
try? FileManager.default.removeItem(at: diskURL)
|
|
FileManager.default.createFile(atPath: diskURL.path, contents: nil)
|
|
let fh = try FileHandle(forWritingTo: diskURL)
|
|
try fh.truncate(atOffset: 64.mib())
|
|
try fh.close()
|
|
|
|
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
|
let socketPath = "/tmp/nbd-\(shortID)-raw.sock"
|
|
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
|
defer { server.stop() }
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
// Attach as raw block, bind mount the device into the container.
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "none",
|
|
source: server.url,
|
|
destination: "/dev/my-disk",
|
|
options: ["bind"]
|
|
))
|
|
// Verify it's a block device, write known data, read it back.
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"test -b /dev/my-disk && printf 'raw-block-works' | dd of=/dev/my-disk bs=512 count=1 conv=sync 2>/dev/null && dd if=/dev/my-disk bs=1 count=15 2>/dev/null",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard output == "raw-block-works" else {
|
|
throw IntegrationError.assert(msg: "expected 'raw-block-works', got '\(output)'")
|
|
}
|
|
}
|
|
|
|
func testContainerNBDVolumeIdentity() async throws {
|
|
let id = "test-container-nbd-volume-identity"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let volumeCount = 5
|
|
var servers: [NBDServer] = []
|
|
|
|
// Create 5 disk images, each pre-filled with unique content.
|
|
for i in 0..<volumeCount {
|
|
let diskURL = try createEXT4DiskImageWithFile(
|
|
testID: id, name: "vol\(i)", filePath: "/id.txt", content: "container-id-\(i)\n")
|
|
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
|
let socketPath = "/tmp/nbd-\(shortID)-vol\(i).sock"
|
|
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
|
servers.append(server)
|
|
}
|
|
defer {
|
|
for server in servers {
|
|
server.stop()
|
|
}
|
|
}
|
|
|
|
// Attach all 5 NBD volumes to a single container and verify each is correct.
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
for i in 0..<volumeCount {
|
|
config.mounts.append(
|
|
Mount.block(format: "ext4", source: servers[i].url, destination: "/mnt\(i)"))
|
|
}
|
|
let readCommands = (0..<volumeCount).map { "cat /mnt\($0)/id.txt" }.joined(separator: " && ")
|
|
config.process.arguments = ["/bin/sh", "-c", readCommands]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
let status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "reader container exited with status \(status)")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lines = output.components(separatedBy: "\n")
|
|
|
|
guard lines.count == volumeCount else {
|
|
throw IntegrationError.assert(msg: "expected \(volumeCount) lines, got \(lines.count): \(output)")
|
|
}
|
|
|
|
for i in 0..<volumeCount {
|
|
let expected = "container-id-\(i)"
|
|
guard lines[i] == expected else {
|
|
throw IntegrationError.assert(msg: "volume \(i): expected '\(expected)', got '\(lines[i])'")
|
|
}
|
|
}
|
|
}
|
|
|
|
func testPodSharedNBDVolume() async throws {
|
|
let id = "test-pod-shared-nbd-volume"
|
|
let bs = try await bootstrap(id)
|
|
|
|
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 pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = [
|
|
.init(
|
|
name: "shared-data",
|
|
source: .nbd(url: URL(string: server.url)!),
|
|
format: "ext4"
|
|
)
|
|
]
|
|
}
|
|
|
|
// Container 1: writes 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 from the same shared volume at a different path and verifies mount type.
|
|
let readerBuffer = BufferWriter()
|
|
try await pod.addContainer("reader", rootfs: rootfs2) { config in
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"sleep 2 && cat /shared/shared.txt && grep /shared /proc/mounts",
|
|
]
|
|
config.process.stdout = readerBuffer
|
|
config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared"))
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
try await pod.startContainer("writer")
|
|
try await pod.startContainer("reader")
|
|
|
|
let writerStatus = try await pod.waitContainer("writer")
|
|
guard writerStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
|
|
}
|
|
|
|
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 output.
|
|
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 reader output.
|
|
let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let readerLines = readerOutput.components(separatedBy: "\n")
|
|
guard readerLines.count >= 2 else {
|
|
throw IntegrationError.assert(msg: "expected at least 2 lines from reader, got: \(readerOutput)")
|
|
}
|
|
guard readerLines[0] == "shared-content" else {
|
|
throw IntegrationError.assert(msg: "expected 'shared-content', got '\(readerLines[0])'")
|
|
}
|
|
try assertVirtioBlockMount(readerLines[1], path: "/shared")
|
|
|
|
// Verify the write landed on the NBD backing file.
|
|
let diskContent = try readFileFromDiskImage(diskURL, path: "/shared.txt")
|
|
guard diskContent == "shared-content" else {
|
|
throw IntegrationError.assert(msg: "NBD backing file: expected 'shared-content', got '\(diskContent)'")
|
|
}
|
|
}
|
|
|
|
func testPodMultipleNBDVolumes() async throws {
|
|
let id = "test-pod-multiple-nbd-volumes"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let (server1, diskURL1) = try createNBDServer(testID: id, name: "vol1")
|
|
defer { server1.stop() }
|
|
|
|
let (server2, diskURL2) = try createNBDServer(testID: id, name: "vol2")
|
|
defer { server2.stop() }
|
|
|
|
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = [
|
|
.init(
|
|
name: "volume-a",
|
|
source: .nbd(url: URL(string: server1.url)!),
|
|
format: "ext4"
|
|
),
|
|
.init(
|
|
name: "volume-b",
|
|
source: .nbd(url: URL(string: server2.url)!),
|
|
format: "ext4"
|
|
),
|
|
]
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"""
|
|
echo aaa > /mnt-a/a.txt && echo bbb > /mnt-b/b.txt \
|
|
&& cat /mnt-a/a.txt && cat /mnt-b/b.txt \
|
|
&& grep /mnt-a /proc/mounts && grep /mnt-b /proc/mounts
|
|
""",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.mounts.append(.sharedMount(name: "volume-a", destination: "/mnt-a"))
|
|
config.mounts.append(.sharedMount(name: "volume-b", destination: "/mnt-b"))
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
try await pod.startContainer("container1")
|
|
|
|
let status = try await pod.waitContainer("container1")
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
|
}
|
|
|
|
try await pod.stop()
|
|
} catch {
|
|
try? await pod.stop()
|
|
throw error
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lines = output.components(separatedBy: "\n")
|
|
|
|
guard lines.count >= 4 else {
|
|
throw IntegrationError.assert(msg: "expected at least 4 lines, got: \(output)")
|
|
}
|
|
|
|
guard lines[0] == "aaa" && lines[1] == "bbb" else {
|
|
throw IntegrationError.assert(msg: "expected 'aaa\\nbbb', got '\(lines[0])\\n\(lines[1])'")
|
|
}
|
|
|
|
try assertVirtioBlockMount(lines[2], path: "/mnt-a")
|
|
try assertVirtioBlockMount(lines[3], path: "/mnt-b")
|
|
|
|
// Verify each write landed on the correct NBD backing file.
|
|
let diskContent1 = try readFileFromDiskImage(diskURL1, path: "/a.txt")
|
|
guard diskContent1 == "aaa" else {
|
|
throw IntegrationError.assert(msg: "NBD backing file vol1: expected 'aaa', got '\(diskContent1)'")
|
|
}
|
|
let diskContent2 = try readFileFromDiskImage(diskURL2, path: "/b.txt")
|
|
guard diskContent2 == "bbb" else {
|
|
throw IntegrationError.assert(msg: "NBD backing file vol2: expected 'bbb', got '\(diskContent2)'")
|
|
}
|
|
}
|
|
|
|
func testPodUnreferencedVolume() async throws {
|
|
let id = "test-pod-unreferenced-volume"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let (server, _) = try createNBDServer(testID: id, name: "unused")
|
|
defer { server.stop() }
|
|
|
|
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = [
|
|
.init(
|
|
name: "unused-vol",
|
|
source: .nbd(url: URL(string: server.url)!),
|
|
format: "ext4"
|
|
)
|
|
]
|
|
}
|
|
|
|
// Container doesn't reference the volume at all.
|
|
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
|
|
config.process.arguments = ["/bin/true"]
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
try await pod.startContainer("container1")
|
|
|
|
let status = try await pod.waitContainer("container1")
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
|
}
|
|
|
|
try await pod.stop()
|
|
} catch {
|
|
try? await pod.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testPodNBDVolumePersistence() async throws {
|
|
let id = "test-pod-nbd-volume-persistence"
|
|
let bs = try await bootstrap(id)
|
|
|
|
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 pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = [
|
|
.init(
|
|
name: "persistent-data",
|
|
source: .nbd(url: URL(string: server.url)!),
|
|
format: "ext4"
|
|
)
|
|
]
|
|
}
|
|
|
|
// First container: write data to the volume.
|
|
try await pod.addContainer("writer", rootfs: rootfs1) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "echo persisted > /data/file.txt && sync"]
|
|
config.mounts.append(.sharedMount(name: "persistent-data", destination: "/data"))
|
|
}
|
|
|
|
// Second container: will read the data after the first is stopped.
|
|
let readerBuffer = BufferWriter()
|
|
try await pod.addContainer("reader", rootfs: rootfs2) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "cat /data/file.txt"]
|
|
config.process.stdout = readerBuffer
|
|
config.mounts.append(.sharedMount(name: "persistent-data", destination: "/data"))
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
|
|
// Start writer, wait for it to finish, then stop it.
|
|
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.stopContainer("writer")
|
|
|
|
// Start reader after writer is stopped — data should persist on the volume.
|
|
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
|
|
}
|
|
|
|
let output = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard output == "persisted" else {
|
|
throw IntegrationError.assert(msg: "expected 'persisted', got '\(output ?? "<nil>")'")
|
|
}
|
|
}
|
|
|
|
func testPodNBDConcurrentWrites() async throws {
|
|
let id = "test-pod-nbd-concurrent-writes"
|
|
let bs = try await bootstrap(id)
|
|
|
|
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 pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = [
|
|
.init(
|
|
name: "shared-vol",
|
|
source: .nbd(url: URL(string: server.url)!),
|
|
format: "ext4"
|
|
)
|
|
]
|
|
}
|
|
|
|
// Both containers write to different files on the same volume concurrently.
|
|
let buffer1 = BufferWriter()
|
|
try await pod.addContainer("c1", rootfs: rootfs1) { config in
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"echo from-c1 > /vol/c1.txt && sync && cat /vol/c1.txt",
|
|
]
|
|
config.process.stdout = buffer1
|
|
config.mounts.append(.sharedMount(name: "shared-vol", destination: "/vol"))
|
|
}
|
|
|
|
let buffer2 = BufferWriter()
|
|
try await pod.addContainer("c2", rootfs: rootfs2) { config in
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"echo from-c2 > /vol/c2.txt && sync && cat /vol/c2.txt",
|
|
]
|
|
config.process.stdout = buffer2
|
|
config.mounts.append(.sharedMount(name: "shared-vol", destination: "/vol"))
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
try await pod.startContainer("c1")
|
|
try await pod.startContainer("c2")
|
|
|
|
let status1 = try await pod.waitContainer("c1")
|
|
guard status1.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "c1 exited with status \(status1)")
|
|
}
|
|
|
|
let status2 = try await pod.waitContainer("c2")
|
|
guard status2.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "c2 exited with status \(status2)")
|
|
}
|
|
|
|
try await pod.stop()
|
|
} catch {
|
|
try? await pod.stop()
|
|
throw error
|
|
}
|
|
|
|
let output1 = String(data: buffer1.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard output1 == "from-c1" else {
|
|
throw IntegrationError.assert(msg: "c1: expected 'from-c1', got '\(output1 ?? "<nil>")'")
|
|
}
|
|
|
|
let output2 = String(data: buffer2.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard output2 == "from-c2" else {
|
|
throw IntegrationError.assert(msg: "c2: expected 'from-c2', got '\(output2 ?? "<nil>")'")
|
|
}
|
|
}
|
|
|
|
func testPodNBDVolumeIdentity() async throws {
|
|
let id = "test-pod-nbd-volume-identity"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create 5 disk images, each pre-filled with unique content.
|
|
let volumeCount = 5
|
|
var servers: [NBDServer] = []
|
|
|
|
for i in 0..<volumeCount {
|
|
let diskURL = try createEXT4DiskImageWithFile(
|
|
testID: id, name: "vol\(i)", filePath: "/id.txt", content: "identity-\(i)\n")
|
|
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
|
let socketPath = "/tmp/nbd-\(shortID)-pvol\(i).sock"
|
|
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
|
servers.append(server)
|
|
}
|
|
defer {
|
|
for server in servers {
|
|
server.stop()
|
|
}
|
|
}
|
|
|
|
// Create a pod with all 5 volumes and verify each is at the right path.
|
|
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
|
config.cpus = 4
|
|
config.memoryInBytes = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
config.volumes = (0..<volumeCount).map { i in
|
|
.init(
|
|
name: "vol-\(i)",
|
|
source: .nbd(url: URL(string: servers[i].url)!),
|
|
format: "ext4"
|
|
)
|
|
}
|
|
}
|
|
|
|
// Container reads from all 5 volumes and prints their content.
|
|
let buffer = BufferWriter()
|
|
try await pod.addContainer("reader", rootfs: bs.rootfs) { config in
|
|
let readCommands = (0..<volumeCount).map { i in
|
|
"cat /mnt\(i)/id.txt"
|
|
}.joined(separator: " && ")
|
|
config.process.arguments = ["/bin/sh", "-c", readCommands]
|
|
config.process.stdout = buffer
|
|
for i in 0..<volumeCount {
|
|
config.mounts.append(.sharedMount(name: "vol-\(i)", destination: "/mnt\(i)"))
|
|
}
|
|
}
|
|
|
|
do {
|
|
try await pod.create()
|
|
try await pod.startContainer("reader")
|
|
|
|
let status = try await pod.waitContainer("reader")
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "reader exited with status \(status)")
|
|
}
|
|
|
|
try await pod.stop()
|
|
} catch {
|
|
try? await pod.stop()
|
|
throw error
|
|
}
|
|
|
|
// Verify each volume's content matches its expected identity.
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
let lines = output.components(separatedBy: "\n")
|
|
|
|
guard lines.count == volumeCount else {
|
|
throw IntegrationError.assert(msg: "expected \(volumeCount) lines, got \(lines.count): \(output)")
|
|
}
|
|
|
|
for i in 0..<volumeCount {
|
|
let expected = "identity-\(i)"
|
|
guard lines[i] == expected else {
|
|
throw IntegrationError.assert(msg: "volume \(i): expected '\(expected)', got '\(lines[i])'")
|
|
}
|
|
}
|
|
}
|
|
}
|