diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 862a18e2..72de285d 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -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) } } diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index f597930e..66537017 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -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) diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 172eb04b..927f5dea 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -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) diff --git a/Sources/Containerization/VMConfiguration.swift b/Sources/Containerization/VMConfiguration.swift index 0e2d47e9..cb86b7c5 100644 --- a/Sources/Containerization/VMConfiguration.swift +++ b/Sources/Containerization/VMConfiguration.swift @@ -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 } } diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index a6916be9..3e1baa30 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -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 { diff --git a/Sources/Containerization/VZVirtualMachineManager.swift b/Sources/Containerization/VZVirtualMachineManager.swift index 73d03df0..531509ea 100644 --- a/Sources/Containerization/VZVirtualMachineManager.swift +++ b/Sources/Containerization/VZVirtualMachineManager.swift @@ -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 diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index f14092cf..b77681e2 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -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 { diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index aa452d1b..15d120a0 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -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 diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index cd29797b..72e226ba 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -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),