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.
This commit is contained in:
Danny Canter
2025-11-14 10:40:32 -08:00
committed by GitHub
parent b501931267
commit 4c761d50c6
9 changed files with 151 additions and 64 deletions
@@ -417,7 +417,7 @@ public struct ContainerManager: Sendable {
config.interfaces = [interface]
config.dns = .init(nameservers: [interface.gateway!])
}
config.bootlog = self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log")
config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log"))
try configuration(&config)
}
}
@@ -60,8 +60,8 @@ public final class LinuxContainer: Container, Sendable {
public var hosts: Hosts?
/// Enable nested virtualization support.
public var virtualization: Bool = false
/// Optional file path to store serial boot logs.
public var bootlog: URL?
/// Optional destination for serial boot logs.
public var bootLog: BootLog?
public init() {}
@@ -77,7 +77,7 @@ public final class LinuxContainer: Container, Sendable {
dns: DNS? = nil,
hosts: Hosts? = nil,
virtualization: Bool = false,
bootlog: URL? = nil
bootLog: BootLog? = nil
) {
self.process = process
self.cpus = cpus
@@ -90,7 +90,7 @@ public final class LinuxContainer: Container, Sendable {
self.dns = dns
self.hosts = hosts
self.virtualization = virtualization
self.bootlog = bootlog
self.bootLog = bootLog
}
}
@@ -371,7 +371,7 @@ extension LinuxContainer {
memoryInBytes: self.memoryInBytes,
interfaces: self.interfaces,
mountsByID: [self.id: [self.rootfs] + self.config.mounts],
bootlog: self.config.bootlog,
bootLog: self.config.bootLog,
nestedVirtualization: self.config.virtualization
)
let creationConfig = StandardVMConfig(configuration: vmConfig)
+2 -2
View File
@@ -49,7 +49,7 @@ public final class LinuxPod: Sendable {
/// Whether nested virtualization should be turned on for the pod.
public var virtualization: Bool = false
/// Optional file path to store serial boot logs.
public var bootlog: URL?
public var bootLog: BootLog?
public init() {}
}
@@ -293,7 +293,7 @@ extension LinuxPod {
memoryInBytes: self.config.memoryInBytes,
interfaces: self.config.interfaces,
mountsByID: mountsByID,
bootlog: self.config.bootlog,
bootLog: self.config.bootLog,
nestedVirtualization: self.config.virtualization
)
let creationConfig = StandardVMConfig(configuration: vmConfig)
+35 -4
View File
@@ -17,6 +17,37 @@
import ContainerizationOCI
import Foundation
/// Destination for boot log (serial console) output.
public struct BootLog: Sendable {
/// The underlying representation of the boot log destination.
internal enum Representation: Sendable {
case file(path: URL, append: Bool)
case fileHandle(FileHandle)
}
internal var base: Representation
/// Write boot logs to a file at the specified path.
///
/// - Parameters:
/// - path: The URL of the file to write boot logs to.
/// - append: Whether to append to an existing file or overwrite it. Defaults to true.
///
/// - Returns: A boot log destination that writes to a file.
public static func file(path: URL, append: Bool = true) -> BootLog {
self.init(base: .file(path: path, append: append))
}
/// Write boot logs to a file handle.
///
/// - Parameter fileHandle: The file handle to write boot logs to.
///
/// - Returns: A boot log destination that writes to a file handle.
public static func fileHandle(_ fileHandle: FileHandle) -> BootLog {
self.init(base: .fileHandle(fileHandle))
}
}
/// Protocol for VM creation configuration. Allows VMMs to extend with specific settings
/// while maintaining a common core configuration.
public protocol VMCreationConfig: Sendable {
@@ -44,8 +75,8 @@ public struct VMConfiguration: Sendable {
/// Mounts organized by metadata ID (e.g. container ID).
/// Each ID maps to an array of mounts for that workload.
public var mountsByID: [String: [Mount]]
/// Optional file path to store serial boot logs.
public var bootlog: URL?
/// Optional destination for serial boot logs.
public var bootLog: BootLog?
/// Enable nested virtualization support. If the VirtualMachineManager
/// does not support this feature, it MUST return an .unsupported ContainerizationError.
public var nestedVirtualization: Bool
@@ -55,14 +86,14 @@ public struct VMConfiguration: Sendable {
memoryInBytes: UInt64 = 1024 * 1024 * 1024,
interfaces: [any Interface] = [],
mountsByID: [String: [Mount]] = [:],
bootlog: URL? = nil,
bootLog: BootLog? = nil,
nestedVirtualization: Bool = false
) {
self.cpus = cpus
self.memoryInBytes = memoryInBytes
self.interfaces = interfaces
self.mountsByID = mountsByID
self.bootlog = bootlog
self.bootLog = bootLog
self.nestedVirtualization = nestedVirtualization
}
}
@@ -55,8 +55,8 @@ struct VZVirtualMachineInstance: Sendable {
public var kernel: Kernel?
/// The root filesystem.
public var initialFilesystem: Mount?
/// File path to store the virtual machine's boot logs.
public var bootlog: URL?
/// Destination for the virtual machine's boot logs.
public var bootLog: BootLog?
init() {
self.cpus = 4
@@ -298,9 +298,17 @@ extension VZVirtualMachineInstance.Configuration {
}
}
private func serialPort(path: URL) throws -> [VZVirtioConsoleDeviceSerialPortConfiguration] {
private func serialPort(destination: BootLog) throws -> [VZVirtioConsoleDeviceSerialPortConfiguration] {
let c = VZVirtioConsoleDeviceSerialPortConfiguration()
c.attachment = try VZFileSerialPortAttachment(url: path, append: true)
switch destination.base {
case .file(let path, let append):
c.attachment = try VZFileSerialPortAttachment(url: path, append: append)
case .fileHandle(let fileHandle):
c.attachment = VZFileHandleSerialPortAttachment(
fileHandleForReading: nil,
fileHandleForWriting: fileHandle
)
}
return [c]
}
@@ -312,11 +320,11 @@ extension VZVirtualMachineInstance.Configuration {
config.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
config.socketDevices = [VZVirtioSocketDeviceConfiguration()]
if let bootlog = self.bootlog {
config.serialPorts = try serialPort(path: bootlog)
if let bootLog = self.bootLog {
config.serialPorts = try serialPort(destination: bootLog)
} else {
// We always supply a serial console. If no explicit path was provided just send em to the void.
config.serialPorts = try serialPort(path: URL(filePath: "/dev/null"))
config.serialPorts = try serialPort(destination: .file(path: URL(filePath: "/dev/null")))
}
config.networkDevices = try self.interfaces.map {
@@ -62,8 +62,8 @@ public struct VZVirtualMachineManager: VirtualMachineManager {
instanceConfig.kernel = self.kernel
instanceConfig.initialFilesystem = self.initialFilesystem
if let bootlog = vmConfig.bootlog {
instanceConfig.bootlog = bootlog
if let bootLog = vmConfig.bootLog {
instanceConfig.bootLog = bootLog
}
instanceConfig.interfaces = vmConfig.interfaces
+74 -27
View File
@@ -30,7 +30,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -50,7 +50,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -115,7 +115,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -145,7 +145,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -188,7 +188,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -262,7 +262,7 @@ extension IntegrationSuite {
config.process.arguments = ["/usr/bin/id"]
config.process.user = .init(uid: 1, gid: 1, additionalGids: [1])
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -287,7 +287,7 @@ extension IntegrationSuite {
// 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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -312,7 +312,7 @@ extension IntegrationSuite {
// 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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -337,7 +337,7 @@ extension IntegrationSuite {
// 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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -359,7 +359,7 @@ extension IntegrationSuite {
config.process.arguments = ["env"]
config.process.terminal = true
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -394,7 +394,7 @@ extension IntegrationSuite {
config.process.arguments = ["env"]
config.process.user = .init(uid: 0, gid: 0)
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -430,7 +430,7 @@ extension IntegrationSuite {
config.process.environmentVariables.append(customHomeEnvvar)
config.process.user = .init(uid: 0, gid: 0)
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -461,7 +461,7 @@ extension IntegrationSuite {
config.process.arguments = ["/bin/hostname"]
config.hostname = "foo-bar"
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -491,7 +491,7 @@ extension IntegrationSuite {
config.process.arguments = ["cat", "/etc/hosts"]
config.hosts = Hosts(entries: [entry])
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -520,7 +520,7 @@ extension IntegrationSuite {
config.process.arguments = ["cat"]
config.process.stdin = StdinBuffer(data: "Hello from test".data(using: .utf8)!)
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -550,7 +550,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
try await container.create()
@@ -578,7 +578,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -618,7 +618,7 @@ extension IntegrationSuite {
) { config in
config.process.arguments = ["/bin/echo", "ContainerManager test"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -656,7 +656,7 @@ extension IntegrationSuite {
) { config in
config.process.arguments = ["/bin/echo", "please stop me"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -695,7 +695,7 @@ extension IntegrationSuite {
) { config in
config.process.arguments = ["/bin/echo", "ContainerManager test"]
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -749,7 +749,7 @@ extension IntegrationSuite {
config.process.arguments = ["mount"]
config.process.terminal = true
config.process.stdout = buffer
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await container.create()
@@ -779,7 +779,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -825,7 +825,7 @@ extension IntegrationSuite {
config.process.arguments = ["sleep", "infinity"]
config.cpus = 2
config.memoryInBytes = 512.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
do {
@@ -948,7 +948,7 @@ extension IntegrationSuite {
direction: .into
)
]
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
do {
@@ -1029,13 +1029,60 @@ extension IntegrationSuite {
return dir
}
func testBootLogFileHandle() async throws {
let id = "test-bootlog-filehandle"
let bs = try await bootstrap(id)
// Create a pipe to capture boot log data
let pipe = Pipe()
let bootLog = BootLog.fileHandle(pipe.fileHandleForWriting)
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["/bin/echo", "test complete"]
config.bootLog = 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) != 0")
}
try pipe.fileHandleForWriting.close()
let bootLogData = try pipe.fileHandleForReading.readToEnd()
guard let bootLogData = bootLogData, bootLogData.count > 0 else {
throw IntegrationError.assert(
msg: "expected to receive boot log data from pipe, but got no data")
}
guard let bootLogString = String(data: bootLogData, encoding: .utf8) else {
throw IntegrationError.assert(
msg: "failed to convert boot log data to UTF8 string")
}
guard bootLogString.count > 100 else {
throw IntegrationError.assert(
msg: "boot log output smaller than expected: got \(bootLogString.count)")
}
} catch {
try? await container.stop()
throw error
}
}
func testLargeStdioOutput() async throws {
let id = "test-large-stdout-stderr-output"
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
config.bootLog = bs.bootLog
}
do {
@@ -1098,7 +1145,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
@@ -1136,7 +1183,7 @@ extension IntegrationSuite {
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
config.bootLog = bs.bootLog
}
do {
+13 -13
View File
@@ -37,7 +37,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
@@ -62,7 +62,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
@@ -99,7 +99,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
let buffer = BufferWriter()
@@ -131,7 +131,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
// Add 5 containers
@@ -176,7 +176,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
@@ -217,7 +217,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
let buffer = BufferWriter()
@@ -250,7 +250,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
@@ -279,7 +279,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
let containerIDs = ["container1", "container2", "container3"]
@@ -307,7 +307,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
@@ -363,7 +363,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
@@ -434,7 +434,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
@@ -505,7 +505,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in
@@ -586,7 +586,7 @@ extension IntegrationSuite {
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootlog = bs.bootlog
config.bootLog = bs.bootLog
}
// Container1 with 1 CPU and 128 MiB memory
+4 -3
View File
@@ -162,7 +162,7 @@ struct IntegrationSuite: AsyncParsableCommand {
static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootlog: URL) {
func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootLog: BootLog) {
let reference = "ghcr.io/linuxcontainers/alpine:3.20"
let store = Self.imageStore
@@ -214,7 +214,7 @@ struct IntegrationSuite: AsyncParsableCommand {
let cl = try fs.clone(to: clPath)
// Create bootlog directory and per-container bootlog path
// Create bootLog directory and per-container bootLog path
let bootlogDirURL = URL(filePath: bootlogDir)
try? FileManager.default.createDirectory(at: bootlogDirURL, withIntermediateDirectories: true)
let bootlogURL = bootlogDirURL.appendingPathComponent("\(testID).log")
@@ -227,7 +227,7 @@ struct IntegrationSuite: AsyncParsableCommand {
group: Self.eventLoop
),
image,
bootlogURL
BootLog.file(path: bootlogURL)
)
}
@@ -299,6 +299,7 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("container test large stdio ingest", testLargeStdioOutput),
Test("process delete idempotency", testProcessDeleteIdempotency),
Test("multiple execs without delete", testMultipleExecsWithoutDelete),
Test("container bootlog using filehandle", testBootLogFileHandle),
// Pods
Test("pod single container", testPodSingleContainer),