Files
container/Sources/Integration/PodTests.swift
T
Danny Canter dc4284a149 Introduce new experimental LinuxPod type (#343)
Closes #319

Introduces a new type capable of running > 1 container in the guest. The
API mostly follows LinuxContainer, and each individual container can be
addressed via any of the methods that require you to pass the
containerID as the first param. Today there's no support for namespace
sharing, but that shouldn't be terrible to support.
2025-10-23 13:59:58 -07:00

733 lines
28 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 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 ArgumentParser
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
extension IntegrationSuite {
/// Clone a rootfs mount to a new location for use by a container in a pod
private func cloneRootfs(_ 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)
}
func testPodSingleContainer() async throws {
let id = "test-pod-single-container"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/true"]
}
try await pod.create()
try await pod.startContainer("container1")
let status = try await pod.waitContainer("container1")
try await pod.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
}
func testPodMultipleContainers() async throws {
let id = "test-pod-multiple-containers"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
config.process.arguments = ["/bin/true"]
}
try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in
config.process.arguments = ["/bin/echo", "hello"]
}
try await pod.create()
try await pod.startContainer("container1")
let status1 = try await pod.waitContainer("container1")
try await pod.startContainer("container2")
let status2 = try await pod.waitContainer("container2")
try await pod.stop()
guard status1.exitCode == 0 else {
throw IntegrationError.assert(msg: "container1 status \(status1) != 0")
}
guard status2.exitCode == 0 else {
throw IntegrationError.assert(msg: "container2 status \(status2) != 0")
}
}
func testPodContainerOutput() async throws {
let id = "test-pod-container-output"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
let buffer = BufferWriter()
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/echo", "hello from pod"]
config.process.stdout = buffer
}
try await pod.create()
try await pod.startContainer("container1")
let status = try await pod.waitContainer("container1")
try await pod.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
guard String(data: buffer.data, encoding: .utf8) == "hello from pod\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout 'hello from pod' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testPodConcurrentContainers() async throws {
let id = "test-pod-concurrent-containers"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
// Add 5 containers
for i in 0..<5 {
try await pod.addContainer("container\(i)", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container\(i)")) { config in
config.process.arguments = ["/bin/sleep", "1"]
}
}
try await pod.create()
// Start all containers concurrently
try await withThrowingTaskGroup(of: Void.self) { group in
for i in 0..<5 {
group.addTask {
try await pod.startContainer("container\(i)")
}
}
try await group.waitForAll()
}
// Wait for all containers concurrently
try await withThrowingTaskGroup(of: Void.self) { group in
for i in 0..<5 {
group.addTask {
let status = try await pod.waitContainer("container\(i)")
if status.exitCode != 0 {
throw IntegrationError.assert(msg: "container\(i) status \(status) != 0")
}
}
}
try await group.waitForAll()
}
try await pod.stop()
}
func testPodExecInContainer() async throws {
let id = "test-pod-exec-in-container"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/sleep", "100"]
}
try await pod.create()
try await pod.startContainer("container1")
let buffer = BufferWriter()
let exec = try await pod.execInContainer("container1", processID: "exec1") { config in
config.arguments = ["/bin/echo", "exec test"]
config.stdout = buffer
}
try await exec.start()
let status = try await exec.wait()
try await exec.delete()
try await pod.killContainer("container1", signal: SIGKILL)
try await pod.waitContainer("container1")
try await pod.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "exec status \(status) != 0")
}
guard String(data: buffer.data, encoding: .utf8) == "exec test\n" else {
throw IntegrationError.assert(
msg: "exec should have returned 'exec test' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testPodContainerHostname() async throws {
let id = "test-pod-container-hostname"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
let buffer = BufferWriter()
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/hostname"]
config.hostname = "my-pod-container"
config.process.stdout = buffer
}
try await pod.create()
try await pod.startContainer("container1")
let status = try await pod.waitContainer("container1")
try await pod.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
guard String(data: buffer.data, encoding: .utf8) == "my-pod-container\n" else {
throw IntegrationError.assert(
msg: "hostname should be 'my-pod-container' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testPodPauseResume() async throws {
let id = "test-pod-pause-resume"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
try await pod.create()
try await pod.startContainer("container1")
// Test pause/resume
try await pod.pause()
try await Task.sleep(for: .milliseconds(500))
try await pod.resume()
try await pod.killContainer("container1", signal: SIGKILL)
try await pod.waitContainer("container1")
try await pod.stop()
}
func testPodStopContainerIdempotency() async throws {
let id = "test-pod-stop-container-idempotency"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/true"]
}
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: "process status \(status) != 0")
}
// Stop container twice - should not fail
try await pod.stopContainer("container1")
try await pod.stopContainer("container1")
try await pod.stop()
}
func testPodListContainers() async throws {
let id = "test-pod-list-containers"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
let containerIDs = ["container1", "container2", "container3"]
for containerID in containerIDs {
try await pod.addContainer(containerID, rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: containerID)) { config in
config.process.arguments = ["/bin/true"]
}
}
let listedContainers = await pod.listContainers()
guard Set(listedContainers) == Set(containerIDs) else {
throw IntegrationError.assert(
msg: "listed containers \(listedContainers) != expected \(containerIDs)")
}
try await pod.create()
try await pod.stop()
}
func testPodContainerStatistics() async throws {
let id = "test-pod-container-statistics"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
do {
try await pod.create()
try await pod.startContainer("container1")
try await pod.startContainer("container2")
let stats = try await pod.statistics()
guard stats.count == 2 else {
throw IntegrationError.assert(msg: "expected 2 container stats, got \(stats.count)")
}
let containerIDs = Set(stats.map { $0.id })
guard containerIDs == Set(["container1", "container2"]) else {
throw IntegrationError.assert(msg: "unexpected container IDs in stats: \(containerIDs)")
}
for stat in stats {
guard stat.process.current > 0 else {
throw IntegrationError.assert(msg: "container \(stat.id) process count should be > 0")
}
guard stat.memory.usageBytes > 0 else {
throw IntegrationError.assert(msg: "container \(stat.id) memory usage should be > 0")
}
print("Container \(stat.id) statistics:")
print(" Processes: \(stat.process.current)")
print(" Memory: \(stat.memory.usageBytes) bytes")
print(" CPU: \(stat.cpu.usageUsec) usec")
}
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
}
func testPodContainerResourceLimits() async throws {
let id = "test-pod-container-resource-limits"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
config.cpus = 2
config.memoryInBytes = 256.mib()
}
do {
try await pod.create()
try await pod.startContainer("container1")
// Verify memory limit
let memoryBuffer = BufferWriter()
let memoryExec = try await pod.execInContainer("container1", processID: "check-memory") { config in
config.arguments = ["cat", "/sys/fs/cgroup/memory.max"]
config.stdout = memoryBuffer
}
try await memoryExec.start()
var status = try await memoryExec.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-memory status \(status) != 0")
}
try await memoryExec.delete()
guard let memoryLimit = String(data: memoryBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse memory.max")
}
let expectedMemory = "\(256.mib())"
guard memoryLimit == expectedMemory else {
throw IntegrationError.assert(msg: "memory.max \(memoryLimit) != expected \(expectedMemory)")
}
// Verify CPU limit
let cpuBuffer = BufferWriter()
let cpuExec = try await pod.execInContainer("container1", processID: "check-cpu") { config in
config.arguments = ["cat", "/sys/fs/cgroup/cpu.max"]
config.stdout = cpuBuffer
}
try await cpuExec.start()
status = try await cpuExec.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-cpu status \(status) != 0")
}
try await cpuExec.delete()
guard let cpuLimit = String(data: cpuBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse cpu.max")
}
let expectedCpu = "200000 100000" // 2 CPUs: quota=200000, period=100000
guard cpuLimit == expectedCpu else {
throw IntegrationError.assert(msg: "cpu.max '\(cpuLimit)' != expected '\(expectedCpu)'")
}
try await pod.killContainer("container1", signal: SIGKILL)
try await pod.waitContainer("container1")
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
}
func testPodContainerFilesystemIsolation() async throws {
let id = "test-pod-container-filesystem-isolation"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
do {
try await pod.create()
try await pod.startContainer("container1")
try await pod.startContainer("container2")
// Write a file in container1
let writeExec = try await pod.execInContainer("container1", processID: "write-file") { config in
config.arguments = ["sh", "-c", "echo 'secret data' > /tmp/container1-secret.txt"]
}
try await writeExec.start()
var status = try await writeExec.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "write-file status \(status) != 0")
}
try await writeExec.delete()
// Verify the file exists in container1
let readBuffer1 = BufferWriter()
let readExec1 = try await pod.execInContainer("container1", processID: "read-file-1") { config in
config.arguments = ["cat", "/tmp/container1-secret.txt"]
config.stdout = readBuffer1
}
try await readExec1.start()
status = try await readExec1.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "read-file-1 status \(status) != 0")
}
try await readExec1.delete()
guard String(data: readBuffer1.data, encoding: .utf8) == "secret data\n" else {
throw IntegrationError.assert(msg: "file content in container1 should be 'secret data'")
}
// Try to read the file from container2 - should fail
let readExec2 = try await pod.execInContainer("container2", processID: "read-file-2") { config in
config.arguments = ["cat", "/tmp/container1-secret.txt"]
}
try await readExec2.start()
status = try await readExec2.wait()
try await readExec2.delete()
// File should NOT exist in container2, so cat should fail
guard status.exitCode != 0 else {
throw IntegrationError.assert(msg: "file should NOT be accessible from container2")
}
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
}
func testPodContainerPIDNamespaceIsolation() async throws {
let id = "test-pod-container-pid-isolation"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
}
do {
try await pod.create()
try await pod.startContainer("container1")
try await pod.startContainer("container2")
// Start a unique process in container1
let sleepExec1 = try await pod.execInContainer("container1", processID: "unique-sleep-1") { config in
config.arguments = ["/bin/sleep", "9999"]
}
try await sleepExec1.start()
// List processes in container1 - should see sleep 9999
let ps1Buffer = BufferWriter()
let psExec1 = try await pod.execInContainer("container1", processID: "ps-1") { config in
config.arguments = ["ps", "aux"]
config.stdout = ps1Buffer
}
try await psExec1.start()
var status = try await psExec1.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "ps-1 status \(status) != 0")
}
try await psExec1.delete()
guard let ps1Output = String(data: ps1Buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to parse ps output from container1")
}
// Verify sleep 9999 is visible in container1
guard ps1Output.contains("sleep 9999") else {
throw IntegrationError.assert(msg: "sleep 9999 should be visible in container1")
}
// List processes in container2 - should NOT see sleep 9999
let ps2Buffer = BufferWriter()
let psExec2 = try await pod.execInContainer("container2", processID: "ps-2") { config in
config.arguments = ["ps", "aux"]
config.stdout = ps2Buffer
}
try await psExec2.start()
status = try await psExec2.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "ps-2 status \(status) != 0")
}
try await psExec2.delete()
guard let ps2Output = String(data: ps2Buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to parse ps output from container2")
}
// Verify sleep 9999 is NOT visible in container2
guard !ps2Output.contains("sleep 9999") else {
throw IntegrationError.assert(msg: "sleep 9999 should NOT be visible in container2 (PID namespace isolation failed)")
}
try await sleepExec1.delete()
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
}
func testPodContainerIndependentResourceLimits() async throws {
let id = "test-pod-container-independent-limits"
let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
}
// Container1 with 1 CPU and 128 MiB memory
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
config.cpus = 1
config.memoryInBytes = 128.mib()
}
// Container2 with 2 CPUs and 256 MiB memory
try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in
config.process.arguments = ["/bin/sleep", "infinity"]
config.cpus = 2
config.memoryInBytes = 256.mib()
}
do {
try await pod.create()
try await pod.startContainer("container1")
try await pod.startContainer("container2")
// Verify container1 memory limit
let mem1Buffer = BufferWriter()
let memExec1 = try await pod.execInContainer("container1", processID: "check-mem-1") { config in
config.arguments = ["cat", "/sys/fs/cgroup/memory.max"]
config.stdout = mem1Buffer
}
try await memExec1.start()
var status = try await memExec1.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-mem-1 status \(status) != 0")
}
try await memExec1.delete()
guard let mem1Limit = String(data: mem1Buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse memory.max from container1")
}
let expectedMem1 = "\(128.mib())"
guard mem1Limit == expectedMem1 else {
throw IntegrationError.assert(msg: "container1 memory.max \(mem1Limit) != expected \(expectedMem1)")
}
// Verify container1 CPU limit
let cpu1Buffer = BufferWriter()
let cpuExec1 = try await pod.execInContainer("container1", processID: "check-cpu-1") { config in
config.arguments = ["cat", "/sys/fs/cgroup/cpu.max"]
config.stdout = cpu1Buffer
}
try await cpuExec1.start()
status = try await cpuExec1.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-cpu-1 status \(status) != 0")
}
try await cpuExec1.delete()
guard let cpu1Limit = String(data: cpu1Buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse cpu.max from container1")
}
let expectedCpu1 = "100000 100000" // 1 CPU
guard cpu1Limit == expectedCpu1 else {
throw IntegrationError.assert(msg: "container1 cpu.max '\(cpu1Limit)' != expected '\(expectedCpu1)'")
}
// Verify container2 memory limit
let mem2Buffer = BufferWriter()
let memExec2 = try await pod.execInContainer("container2", processID: "check-mem-2") { config in
config.arguments = ["cat", "/sys/fs/cgroup/memory.max"]
config.stdout = mem2Buffer
}
try await memExec2.start()
status = try await memExec2.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-mem-2 status \(status) != 0")
}
try await memExec2.delete()
guard let mem2Limit = String(data: mem2Buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse memory.max from container2")
}
let expectedMem2 = "\(256.mib())"
guard mem2Limit == expectedMem2 else {
throw IntegrationError.assert(msg: "container2 memory.max \(mem2Limit) != expected \(expectedMem2)")
}
// Verify container2 CPU limit
let cpu2Buffer = BufferWriter()
let cpuExec2 = try await pod.execInContainer("container2", processID: "check-cpu-2") { config in
config.arguments = ["cat", "/sys/fs/cgroup/cpu.max"]
config.stdout = cpu2Buffer
}
try await cpuExec2.start()
status = try await cpuExec2.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-cpu-2 status \(status) != 0")
}
try await cpuExec2.delete()
guard let cpu2Limit = String(data: cpu2Buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
throw IntegrationError.assert(msg: "failed to parse cpu.max from container2")
}
let expectedCpu2 = "200000 100000" // 2 CPUs
guard cpu2Limit == expectedCpu2 else {
throw IntegrationError.assert(msg: "container2 cpu.max '\(cpu2Limit)' != expected '\(expectedCpu2)'")
}
try await pod.stop()
} catch {
try? await pod.stop()
throw error
}
}
}