diff --git a/Sources/Containerization/LinuxProcessConfiguration.swift b/Sources/Containerization/LinuxProcessConfiguration.swift index 3c961d18..65ec3ae4 100644 --- a/Sources/Containerization/LinuxProcessConfiguration.swift +++ b/Sources/Containerization/LinuxProcessConfiguration.swift @@ -17,6 +17,193 @@ import ContainerizationOCI import ContainerizationOS +/// A resource limit (rlimit) configuration for a container process. +public struct LinuxRLimit: Sendable, Hashable { + /// The kind of resource limit. + public var kind: Kind + /// The hard limit value. + public var hard: UInt64 + /// The soft limit value. + public var soft: UInt64 + + /// Creates a new resource limit. + /// + /// - Parameters: + /// - kind: The kind of resource limit. + /// - hard: The hard limit value. + /// - soft: The soft limit value. + public init(kind: Kind, hard: UInt64, soft: UInt64) { + self.kind = kind + self.hard = hard + self.soft = soft + } + + /// Creates a new resource limit with the same value for both hard and soft limits. + /// + /// - Parameters: + /// - kind: The kind of resource limit. + /// - limit: The limit value for both hard and soft limits. + public init(kind: Kind, limit: UInt64) { + self.kind = kind + self.hard = limit + self.soft = limit + } + + /// Convert to OCI POSIXRlimit format for transport. + public func toOCI() -> POSIXRlimit { + POSIXRlimit(type: self.kind.description, hard: self.hard, soft: self.soft) + } +} + +extension LinuxRLimit { + /// The kind of resource limit. + public struct Kind: Sendable, Hashable { + private enum Value: Hashable, Sendable, CaseIterable { + case addressSpace + case coreFileSize + case cpuTime + case dataSize + case fileSize + case locks + case lockedMemory + case messageQueue + case nice + case openFiles + case numberOfProcesses + case residentSetSize + case realtimePriority + case realtimeTimeout + case signalsPending + case stackSize + } + + private var value: Value + private init(_ value: Value) { + self.value = value + } + + /// Maximum size of the process's virtual memory (address space) in bytes. + public static var addressSpace: Self { + Self(.addressSpace) + } + + /// Maximum size of a core file in bytes. + public static var coreFileSize: Self { + Self(.coreFileSize) + } + + /// Maximum amount of CPU time the process can consume in seconds. + public static var cpuTime: Self { + Self(.cpuTime) + } + + /// Maximum size of the process's data segment in bytes. + public static var dataSize: Self { + Self(.dataSize) + } + + /// Maximum size of files the process may create in bytes. + public static var fileSize: Self { + Self(.fileSize) + } + + /// Maximum number of file locks. + public static var locks: Self { + Self(.locks) + } + + /// Maximum number of bytes of memory that may be locked into RAM. + public static var lockedMemory: Self { + Self(.lockedMemory) + } + + /// Maximum number of bytes that can be allocated for POSIX message queues. + public static var messageQueue: Self { + Self(.messageQueue) + } + + /// Maximum nice value that can be set. + public static var nice: Self { + Self(.nice) + } + + /// Maximum number of open file descriptors. + public static var openFiles: Self { + Self(.openFiles) + } + + /// Maximum number of processes that can be created by the user. + public static var numberOfProcesses: Self { + Self(.numberOfProcesses) + } + + /// Maximum size of the process's resident set (physical memory) in bytes. + public static var residentSetSize: Self { + Self(.residentSetSize) + } + + /// Maximum real-time scheduling priority. + public static var realtimePriority: Self { + Self(.realtimePriority) + } + + /// Maximum amount of CPU time for real-time scheduling in microseconds. + public static var realtimeTimeout: Self { + Self(.realtimeTimeout) + } + + /// Maximum number of signals that may be queued. + public static var signalsPending: Self { + Self(.signalsPending) + } + + /// Maximum size of the process stack in bytes. + public static var stackSize: Self { + Self(.stackSize) + } + } +} + +extension LinuxRLimit.Kind: CustomStringConvertible { + /// The OCI string representation of the resource limit kind. + public var description: String { + switch self.value { + case .addressSpace: + "RLIMIT_AS" + case .coreFileSize: + "RLIMIT_CORE" + case .cpuTime: + "RLIMIT_CPU" + case .dataSize: + "RLIMIT_DATA" + case .fileSize: + "RLIMIT_FSIZE" + case .locks: + "RLIMIT_LOCKS" + case .lockedMemory: + "RLIMIT_MEMLOCK" + case .messageQueue: + "RLIMIT_MSGQUEUE" + case .nice: + "RLIMIT_NICE" + case .openFiles: + "RLIMIT_NOFILE" + case .numberOfProcesses: + "RLIMIT_NPROC" + case .residentSetSize: + "RLIMIT_RSS" + case .realtimePriority: + "RLIMIT_RTPRIO" + case .realtimeTimeout: + "RLIMIT_RTTIME" + case .signalsPending: + "RLIMIT_SIGPENDING" + case .stackSize: + "RLIMIT_STACK" + } + } +} + /// User-friendly Linux capabilities configuration public struct LinuxCapabilities: Sendable { /// Capabilities that define the maximum set of capabilities a process can have @@ -140,7 +327,7 @@ public struct LinuxProcessConfiguration: Sendable { /// The user the container process will run as. public var user: ContainerizationOCI.User = .init() /// The rlimits for the container process. - public var rlimits: [POSIXRlimit] = [] + public var rlimits: [LinuxRLimit] = [] /// The Linux capabilities for the container process. public var capabilities: LinuxCapabilities = .allCapabilities /// Whether to allocate a pseudo terminal for the process. If you'd like interactive @@ -161,7 +348,7 @@ public struct LinuxProcessConfiguration: Sendable { environmentVariables: [String] = ["PATH=\(Self.defaultPath)"], workingDirectory: String = "/", user: ContainerizationOCI.User = .init(), - rlimits: [POSIXRlimit] = [], + rlimits: [LinuxRLimit] = [], capabilities: LinuxCapabilities = .allCapabilities, terminal: Bool = false, stdin: ReaderStream? = nil, @@ -208,7 +395,7 @@ public struct LinuxProcessConfiguration: Sendable { env: self.environmentVariables, capabilities: self.capabilities.toOCI(), user: self.user, - rlimits: self.rlimits, + rlimits: self.rlimits.map { $0.toOCI() }, terminal: self.terminal ) } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 893fbe9b..c299930b 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -2317,4 +2317,139 @@ extension IntegrationSuite { throw error } } + + func testRLimitOpenFiles() async throws { + let id = "test-rlimit-open-files" + + 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 = ["sh", "-c", "ulimit -n"] + config.process.rlimits = [ + LinuxRLimit(kind: .openFiles, hard: 2048, soft: 1024) + ] + 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)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + // ulimit -n returns the soft limit + guard output == "1024" else { + throw IntegrationError.assert(msg: "expected soft limit '1024', got '\(output)'") + } + } + + func testRLimitMultiple() async throws { + let id = "test-rlimit-multiple" + + let bs = try await bootstrap(id) + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // Read /proc/self/limits to verify multiple rlimits are set + config.process.arguments = ["cat", "/proc/self/limits"] + config.process.rlimits = [ + LinuxRLimit(kind: .openFiles, hard: 4096, soft: 2048), + LinuxRLimit(kind: .stackSize, hard: 16_777_216, soft: 8_388_608), + LinuxRLimit(kind: .coreFileSize, hard: 0, soft: 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") + } + + // Parse /proc/self/limits and verify the values + // Format: "Limit Name Soft Limit Hard Limit Units" + let lines = output.split(separator: "\n") + + // Helper to find and verify a limit line + func verifyLimit(name: String, expectedSoft: String, expectedHard: String) throws { + guard let line = lines.first(where: { $0.contains(name) }) else { + throw IntegrationError.assert(msg: "limit '\(name)' not found in output") + } + let parts = line.split(whereSeparator: { $0.isWhitespace }).map(String.init) + // The line format varies, but soft and hard are typically the last numeric values before units + guard parts.contains(expectedSoft) && parts.contains(expectedHard) else { + throw IntegrationError.assert( + msg: "limit '\(name)' expected soft=\(expectedSoft) hard=\(expectedHard), got: \(line)") + } + } + + try verifyLimit(name: "Max open files", expectedSoft: "2048", expectedHard: "4096") + try verifyLimit(name: "Max stack size", expectedSoft: "8388608", expectedHard: "16777216") + try verifyLimit(name: "Max core file size", expectedSoft: "0", expectedHard: "0") + } + + func testRLimitExec() async throws { + let id = "test-rlimit-exec" + + let bs = try await bootstrap(id) + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Exec a process with rlimits set + let buffer = BufferWriter() + let exec = try await container.exec("rlimit-exec") { config in + config.arguments = ["sh", "-c", "ulimit -n"] + config.rlimits = [ + LinuxRLimit(kind: .openFiles, hard: 512, soft: 256) + ] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "exec status \(status) != 0") + } + + guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard output == "256" else { + throw IntegrationError.assert(msg: "expected soft limit '256', got '\(output)'") + } + + try await container.kill(SIGKILL) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } } diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index db7d367d..2e3e8e5c 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -1347,4 +1347,96 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "container2 should NOT have pod-level hosts entry, got: \(output2)") } } + + func testPodRLimitOpenFiles() async throws { + let id = "test-pod-rlimit-open-files" + + 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 = ["sh", "-c", "ulimit -n"] + config.process.rlimits = [ + LinuxRLimit(kind: .openFiles, hard: 2048, soft: 1024) + ] + 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 let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + // ulimit -n returns the soft limit + guard output == "1024" else { + throw IntegrationError.assert(msg: "expected soft limit '1024', got '\(output)'") + } + } + + func testPodRLimitExec() async throws { + let id = "test-pod-rlimit-exec" + + 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 = ["sleep", "100"] + } + + do { + try await pod.create() + try await pod.startContainer("container1") + + // Exec a process with rlimits set + let buffer = BufferWriter() + let exec = try await pod.execInContainer("container1", processID: "rlimit-exec") { config in + config.arguments = ["sh", "-c", "ulimit -n"] + config.rlimits = [ + LinuxRLimit(kind: .openFiles, hard: 512, soft: 256) + ] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "exec status \(status) != 0") + } + + guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard output == "256" else { + throw IntegrationError.assert(msg: "expected soft limit '256', got '\(output)'") + } + + try await pod.killContainer("container1", signal: SIGKILL) + try await pod.waitContainer("container1") + try await pod.stop() + } catch { + try? await pod.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index d252c3b3..0cae2ffe 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -335,6 +335,9 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container single file mount read-only", testSingleFileMountReadOnly), Test("container single file mount write-back", testSingleFileMountWriteBack), Test("container single file mount symlink", testSingleFileMountSymlink), + Test("container rlimit open files", testRLimitOpenFiles), + Test("container rlimit multiple", testRLimitMultiple), + Test("container rlimit exec", testRLimitExec), // Pods Test("pod single container", testPodSingleContainer), @@ -362,6 +365,8 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod level DNS with container override", testPodLevelDNSWithContainerOverride), Test("pod level hosts", testPodLevelHosts), Test("pod level hosts with container override", testPodLevelHostsWithContainerOverride), + Test("pod rlimit open files", testPodRLimitOpenFiles), + Test("pod rlimit exec", testPodRLimitExec), ] + macOS26Tests() let filteredTests: [Test]