Files
container/Sources/Integration/PodTests.swift
T
Danny Canter 4c761d50c6 Add new FileHandle option for serial console output (#410)
Taking in a filehandle gives the user quite a bit more freedom on how to
handle boot log output. They can set up a kqueue watch on it and
redirect output somewhere else etc etc. The implementation for this has
us take in a new BootLog type that has two options:

1. .file, which is analogous to what we had prior. Just provide a URL
and a true by default append field.
2. .fileHandle which is the new addition. Can pass any fd that is
writable, and the VMM should write serial console output to it.
2025-11-14 10:40:32 -08:00

706 lines
27 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 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
}
}
}