Files
container/Sources/Integration/ContainerTests.swift
T
Danny Canter 22f7b6ac13 LinuxContainer: Add bootlog as configuration field (#344)
Closes #227

Previously, the bootlog was supplied once in the constructor to
VZVirtualMachineManager which meant that if you used this same manager
for multiple ctrs that all logs would end up going to the same file,
which becomes quite cumbersome to follow..

This change moves bootlog to be a container configuration param and also
moves it to be a VMConfiguration param, so it can be threaded through
from LinuxContainer -> vmm.create() and be truly container unique now.
The largest driver for this was the integration tests which today every
single test spits out logs to a singular file, making guest
investigations tricky to actually look into.

Result after:
```
➜  containerization git:(bootlog-per-ctr) ✗ ls -alh bin/bootlogs
total 1520
drwxr-xr-x@ 24 dcantah  staff   768B Oct 22 17:34 .
drwxr-xr-x@  8 dcantah  staff   256B Oct 22 17:34 ..
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-cat-mount.log
-rw-------@  1 dcantah  staff    22K Oct 22 17:34 test-cgroup-limits.log
-rw-------@  1 dcantah  staff   249K Oct 22 17:34 test-concurrent-processes-output-stress.log
-rw-------@  1 dcantah  staff   167K Oct 22 17:34 test-concurrent-processes.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-devconsole.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-hostname.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-hosts-file.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-manager.log
-rw-------@  1 dcantah  staff    22K Oct 22 17:34 test-container-reuse.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-statistics.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-container-stdin.log
-rw-------@  1 dcantah  staff     0B Oct 22 17:34 test-nested-virt.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-pause-resume-io.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-pause-resume-wait.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-pause-resume.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-custom-home-envvar.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-echo-hi.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-false.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-home-envvar.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-true.log
-rw-------@  1 dcantah  staff    11K Oct 22 17:34 test-process-tty-envvar.log
-rw-------@  1 dcantah  staff    38K Oct 22 17:34 test-process-user.log
```
2025-10-23 03:27:24 -07:00

1008 lines
36 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 Crypto
import Foundation
import Logging
extension IntegrationSuite {
func testProcessTrue() async throws {
let id = "test-process-true"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/true"]
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: "process status \(status) != 0")
}
}
func testProcessFalse() async throws {
let id = "test-process-false"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/false"]
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 == 1 else {
throw IntegrationError.assert(msg: "process status \(status) != 1")
}
}
final class BufferWriter: Writer {
// `data` isn't used concurrently.
nonisolated(unsafe) var data = Data()
func write(_ data: Data) throws {
guard data.count > 0 else {
return
}
self.data.append(data)
}
func close() throws {
return
}
}
final class StdinBuffer: ReaderStream {
let data: Data
init(data: Data) {
self.data = data
}
func stream() -> AsyncStream<Data> {
let (stream, cont) = AsyncStream<Data>.makeStream()
cont.yield(self.data)
cont.finish()
return stream
}
}
func testProcessEchoHi() async throws {
let id = "test-process-echo-hi"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/echo", "hi"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
do {
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: "process status \(status) != 1")
}
guard String(data: buffer.data, encoding: .utf8) == "hi\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout 'hi' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
} catch {
try? await container.stop()
throw error
}
}
func testMultipleConcurrentProcesses() async throws {
let id = "test-concurrent-processes"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sleep", "1000"]
config.bootlog = bs.bootlog
}
do {
try await container.create()
try await container.start()
try await withThrowingTaskGroup(of: Void.self) { group in
for i in 0...80 {
let exec = try await container.exec("exec-\(i)") { config in
config.arguments = ["/bin/true"]
}
group.addTask {
try await exec.start()
let status = try await exec.wait()
if status.exitCode != 0 {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
try await exec.delete()
}
}
// wait for all the exec'd processes.
try await group.waitForAll()
print("all group processes exit")
// kill the init process.
try await container.kill(SIGKILL)
let status = try await container.wait()
try await container.stop()
print("Init process exited with: \(status)")
}
} catch {
throw error
}
}
func testMultipleConcurrentProcessesOutputStress() async throws {
let id = "test-concurrent-processes-output-stress"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/sleep", "1000"]
config.bootlog = bs.bootlog
}
do {
try await container.create()
try await container.start()
let buffer = BufferWriter()
let exec = try await container.exec("expected-value") { config in
config.arguments = [
"sh",
"-c",
"dd if=/dev/random of=/tmp/bytes bs=1M count=20 status=none ; sha256sum /tmp/bytes",
]
config.stdout = buffer
}
try await exec.start()
let status = try await exec.wait()
if status.exitCode != 0 {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
let output = String(data: buffer.data, encoding: .utf8)!
let expected = String(output.split(separator: " ").first!)
try await withThrowingTaskGroup(of: Void.self) { group in
for i in 0...80 {
let idx = i
group.addTask {
let buffer = BufferWriter()
let exec = try await container.exec("exec-\(idx)") { config in
config.arguments = ["cat", "/tmp/bytes"]
config.stdout = buffer
}
try await exec.start()
let status = try await exec.wait()
if status.exitCode != 0 {
throw IntegrationError.assert(msg: "process \(idx) status \(status) != 0")
}
var hasher = SHA256()
hasher.update(data: buffer.data)
let hash = hasher.finalize().digestString.trimmingDigestPrefix
guard hash == expected else {
throw IntegrationError.assert(
msg: "process \(idx) output \(hash) != expected \(expected)")
}
try await exec.delete()
}
}
// wait for all the exec'd processes.
try await group.waitForAll()
print("all group processes exit")
// kill the init process.
try await container.kill(SIGKILL)
try await container.wait()
try await container.stop()
}
}
}
func testProcessUser() async throws {
let id = "test-process-user"
let bs = try await bootstrap(id)
var buffer = BufferWriter()
var container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
config.process.user = .init(uid: 1, gid: 1, additionalGids: [1])
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
var status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
var expected = "uid=1(bin) gid=1(bin) groups=1(bin)"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Try some uid that doesn't exist. This is supported.
config.process.user = .init(uid: 40000, gid: 40000)
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
expected = "uid=40000 gid=40000 groups=40000"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Try some uid that doesn't exist. This is supported.
config.process.user = .init(username: "40000:40000")
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
expected = "uid=40000 gid=40000 groups=40000"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
buffer = BufferWriter()
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/usr/bin/id"]
// Now for our final trick, try and run a username that doesn't exist.
config.process.user = .init(username: "thisdoesntexist")
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
try await container.create()
do {
try await container.start()
} catch {
return
}
throw IntegrationError.assert(msg: "container start should have failed")
}
// Ensure if we ask for a terminal we set TERM.
func testProcessTtyEnvvar() async throws {
let id = "test-process-tty-envvar"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["env"]
config.process.terminal = true
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: "process status \(status) != 0")
}
guard let str = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(
msg: "failed to convert standard output to a UTF8 string")
}
let homeEnvvar = "TERM=xterm"
guard str.contains(homeEnvvar) else {
throw IntegrationError.assert(
msg: "process should have TERM environment variable defined")
}
}
// Make sure we set HOME by default if we can find it in /etc/passwd in the guest.
func testProcessHomeEnvvar() async throws {
let id = "test-process-home-envvar"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["env"]
config.process.user = .init(uid: 0, gid: 0)
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: "process status \(status) != 0")
}
guard let str = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(
msg: "failed to convert standard output to a UTF8 string")
}
let homeEnvvar = "HOME=/root"
guard str.contains(homeEnvvar) else {
throw IntegrationError.assert(
msg: "process should have HOME environment variable defined")
}
}
func testProcessCustomHomeEnvvar() async throws {
let id = "test-process-custom-home-envvar"
let bs = try await bootstrap(id)
let customHomeEnvvar = "HOME=/tmp/custom/home"
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["sh", "-c", "echo HOME=$HOME"]
config.process.environmentVariables.append(customHomeEnvvar)
config.process.user = .init(uid: 0, gid: 0)
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: "process status \(status) != 0")
}
guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}
guard output.contains(customHomeEnvvar) else {
throw IntegrationError.assert(msg: "process should have preserved custom HOME environment variable, expected \(customHomeEnvvar), got: \(output)")
}
}
func testHostname() async throws {
let id = "test-container-hostname"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/hostname"]
config.hostname = "foo-bar"
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: "process status \(status) != 0")
}
let expected = "foo-bar"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testHostsFile() async throws {
let id = "test-container-hosts-file"
let bs = try await bootstrap(id)
let entry = Hosts.Entry.localHostIPV4(comment: "Testaroo")
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["cat", "/etc/hosts"]
config.hosts = Hosts(entries: [entry])
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: "process status \(status) != 0")
}
let expected = entry.rendered
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testProcessStdin() async throws {
let id = "test-container-stdin"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["cat"]
config.process.stdin = StdinBuffer(data: "Hello from test".data(using: .utf8)!)
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: "process status \(status) != 0")
}
let expected = "Hello from test"
guard String(data: buffer.data, encoding: .utf8) == "\(expected)" else {
throw IntegrationError.assert(
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
}
}
func testMounts() async throws {
let id = "test-cat-mount"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
let directory = try createMountDirectory()
config.process.arguments = ["/bin/cat", "/mnt/hi.txt"]
config.mounts.append(.share(source: directory.path, destination: "/mnt"))
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: "process status \(status) != 0")
}
let value = String(data: buffer.data, encoding: .utf8)
guard value == "hello" else {
throw IntegrationError.assert(
msg: "process should have returned from file 'hello' != '\(String(data: buffer.data, encoding: .utf8)!)")
}
}
func testPauseResume() async throws {
let id = "test-pause-resume"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["sleep", "infinity"]
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
// Very simple test of can we perform actions on the container after pause/resume.
try await container.pause()
try await Task.sleep(for: .milliseconds(500))
try await container.resume()
try await container.kill(SIGKILL)
try await container.wait()
try await container.stop()
}
func testPauseResumeWait() async throws {
let id = "test-pause-resume-wait"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["sleep", "2"]
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
let t = Task {
try await container.wait(timeoutInSeconds: 5)
}
try await Task.sleep(for: .milliseconds(25))
try await container.pause()
try await Task.sleep(for: .milliseconds(500))
try await container.resume()
let status = try await t.value
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
try await container.stop()
}
func testPauseResumeIO() async throws {
let id = "test-pause-resume-io"
let bs = try await bootstrap(id)
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["ping", "-c", "5", "localhost"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
try await container.create()
try await container.start()
try await container.pause()
try await Task.sleep(for: .seconds(2))
try await container.resume()
try await container.wait()
guard let str = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to utf8")
}
// Should be 10 lines long. 5 of "filler" and 5 of actual
// output, however one of the lines is a blank newline.
let expectedLines = 9
let lines = str.split(separator: "\n")
guard lines.count == expectedLines else {
throw IntegrationError.assert(msg: "expected \(expectedLines), got \(lines.count)")
}
try await container.stop()
}
func testNestedVirtualizationEnabled() async throws {
let id = "test-nested-virt"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/true"]
config.virtualization = true
config.bootlog = bs.bootlog
}
do {
try await container.create()
try await container.start()
} catch {
if let err = error as? ContainerizationError {
if err.code == .unsupported {
throw SkipTest(reason: err.message)
}
}
}
let status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
}
func testContainerManagerCreate() async throws {
let id = "test-container-manager"
// Get the kernel from bootstrap
let bs = try await bootstrap(id)
// Create ContainerManager with kernel and initfs reference
var manager = try ContainerManager(vmm: bs.vmm)
defer {
try? manager.delete(id)
}
let buffer = BufferWriter()
let container = try await manager.create(
id,
image: bs.image,
rootfs: bs.rootfs
) { config in
config.process.arguments = ["/bin/echo", "ContainerManager test"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
// Start the container
try await container.create()
try await container.start()
// Wait for completion
let status = try await container.wait()
try await container.stop()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
let output = String(data: buffer.data, encoding: .utf8)
guard output == "ContainerManager test\n" else {
throw IntegrationError.assert(
msg: "process should have returned 'ContainerManager test' != '\(output ?? "nil")'")
}
}
func testContainerStopIdempotency() async throws {
let id = "test-container-stop-idempotency"
// Get the kernel from bootstrap
let bs = try await bootstrap(id)
// Create ContainerManager with kernel and initfs reference
var manager = try ContainerManager(vmm: bs.vmm)
defer {
try? manager.delete(id)
}
let buffer = BufferWriter()
let container = try await manager.create(
id,
image: bs.image,
rootfs: bs.rootfs
) { config in
config.process.arguments = ["/bin/echo", "please stop me"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
// Start the container
try await container.create()
try await container.start()
// Wait for completion
let status = try await container.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
try await container.stop()
// Second go around should return with no problems.
try await container.stop()
let output = String(data: buffer.data, encoding: .utf8)
guard output == "ContainerManager test\n" else {
throw IntegrationError.assert(
msg: "process should have returned 'ContainerManager test' != '\(output ?? "nil")'")
}
}
func testContainerReuse() async throws {
let id = "test-container-reuse"
// Get the kernel from bootstrap
let bs = try await bootstrap(id)
// Create ContainerManager with kernel and initfs reference
var manager = try ContainerManager(vmm: bs.vmm)
defer {
try? manager.delete(id)
}
let buffer = BufferWriter()
let container = try await manager.create(
id,
image: bs.image,
rootfs: bs.rootfs
) { config in
config.process.arguments = ["/bin/echo", "ContainerManager test"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
}
// Start the container
try await container.create()
try await container.start()
// Wait for completion
var status = try await container.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
try await container.stop()
// Recreate things.
try await container.create()
try await container.start()
// Wait for completion.. again.
status = try await container.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "process status \(status) != 0")
}
let output = String(data: buffer.data, encoding: .utf8)
let expected = "ContainerManager test\nContainerManager test\n"
guard output == expected else {
throw IntegrationError.assert(
msg: "process should have returned '\(expected)' != '\(output ?? "nil")'")
}
}
func testContainerDevConsole() async throws {
let id = "test-container-devconsole"
let bs = try await bootstrap(id)
var manager = try ContainerManager(vmm: bs.vmm)
defer {
try? manager.delete(id)
}
let buffer = BufferWriter()
let container = try await manager.create(
id,
image: bs.image,
rootfs: bs.rootfs
) { config in
// We mount devtmpfs by default, and while this includes creating
// /dev/console typically that'll be pointing to /dev/hvc0 (the
// virtio serial console). This is just a character device, so a trivial
// way to check that our bind mounted console setup worked is by just
// parsing `mount`'s output and looking for /dev/console as it wouldn't
// be there normally without our dance.
config.process.arguments = ["mount"]
config.process.terminal = true
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: "process status \(status) != 0")
}
guard let str = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(
msg: "failed to convert standard output to a UTF8 string")
}
let devConsole = "/dev/console"
guard str.contains(devConsole) else {
throw IntegrationError.assert(
msg: "process should have \(devConsole) in `mount` output")
}
}
func testContainerStatistics() async throws {
let id = "test-container-statistics"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["sleep", "infinity"]
config.bootlog = bs.bootlog
}
do {
try await container.create()
try await container.start()
let stats = try await container.statistics()
guard stats.id == id else {
throw IntegrationError.assert(msg: "stats container ID '\(stats.id)' != '\(id)'")
}
guard stats.process.current > 0 else {
throw IntegrationError.assert(msg: "process count should be > 0, got \(stats.process.current)")
}
guard stats.memory.usageBytes > 0 else {
throw IntegrationError.assert(msg: "memory usage should be > 0, got \(stats.memory.usageBytes)")
}
guard stats.cpu.usageUsec > 0 else {
throw IntegrationError.assert(msg: "CPU usage should be > 0, got \(stats.cpu.usageUsec)")
}
print("Container statistics:")
print(" Processes: \(stats.process.current)")
print(" Memory: \(stats.memory.usageBytes) bytes")
print(" CPU: \(stats.cpu.usageUsec) usec")
print(" Networks: \(stats.networks.count) interfaces")
try await container.stop()
} catch {
try? await container.stop()
throw error
}
}
func testCgroupLimits() async throws {
let id = "test-cgroup-limits"
let bs = try await bootstrap(id)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["sleep", "infinity"]
config.cpus = 2
config.memoryInBytes = 512.mib()
config.bootlog = bs.bootlog
}
do {
try await container.create()
try await container.start()
// Start an exec with sleep infinity
let sleepExec = try await container.exec("sleep-exec") { config in
config.arguments = ["sleep", "infinity"]
}
try await sleepExec.start()
// Verify we have 3 PIDs in cgroup.procs: init, exec sleep, and cat itself
let procsBuffer = BufferWriter()
let procsExec = try await container.exec("check-procs") { config in
config.arguments = ["cat", "/sys/fs/cgroup/cgroup.procs"]
config.stdout = procsBuffer
}
try await procsExec.start()
var status = try await procsExec.wait()
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "check-procs status \(status) != 0")
}
try await procsExec.delete()
guard let procsContent = String(data: procsBuffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to parse cgroup.procs")
}
let pids = procsContent.split(separator: "\n").filter { !$0.isEmpty }
guard pids.count == 3 else {
throw IntegrationError.assert(msg: "expected 3 PIDs in cgroup.procs, got \(pids.count): \(procsContent)")
}
// Verify memory limit
let memoryBuffer = BufferWriter()
let memoryExec = try await container.exec("check-memory") { config in
config.arguments = ["cat", "/sys/fs/cgroup/memory.max"]
config.stdout = memoryBuffer
}
try await memoryExec.start()
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 = "\(512.mib())"
guard memoryLimit == expectedMemory else {
throw IntegrationError.assert(msg: "memory.max \(memoryLimit) != expected \(expectedMemory)")
}
// Verify CPU limit
let cpuBuffer = BufferWriter()
let cpuExec = try await container.exec("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 container.kill(SIGKILL)
try await container.wait()
try await container.stop()
} catch {
try? await container.stop()
throw error
}
}
private func createMountDirectory() throws -> URL {
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8)
return dir
}
}