//===----------------------------------------------------------------------===// // Copyright © 2026 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. //===----------------------------------------------------------------------===// #if os(Linux) import Containerization import ContainerizationError import ContainerizationOCI import ContainerizationOS import Foundation import Logging import Synchronization /// A container process implementation that uses runc as the OCI runtime final class RuncProcess: ContainerProcess, Sendable { // swiftlint: disable type_name protocol IO: Sendable { func attachConsole(fd: Int32) throws func create() throws func getIO() -> Runc.IO func closeAfterExec() throws func resize(size: Terminal.Size) throws func close() throws func closeStdin() throws } // swiftlint: enable type_name private enum ProcessState { case initial case creating case running(pid: Int32) case exited(ContainerExitStatus) } private struct State { var state: ProcessState = .initial var waiters: [CheckedContinuation] = [] } let id: String private let log: Logger private let runc: Runc private let io: IO private let state: Mutex private let terminal: Bool private let bundle: ContainerizationOCI.Bundle private let consoleSocket: ConsoleSocket? var pid: Int32? { self.state.withLock { switch $0.state { case .running(let pid): return pid default: return nil } } } init( id: String, stdio: HostStdio, bundle: ContainerizationOCI.Bundle, runc: Runc, log: Logger ) throws { self.id = id var log = log log[metadataKey: "id"] = "\(id)" self.log = log self.runc = runc self.bundle = bundle self.terminal = stdio.terminal var io: IO var consoleSocket: ConsoleSocket? = nil if stdio.terminal { log.info("setting up terminal I/O for runc") let socket = try ConsoleSocket.temporary() consoleSocket = socket io = try RuncTerminalIO( stdio: stdio, log: log ) } else { io = RuncStandardIO( stdio: stdio, log: log ) } log.info("starting I/O for runc") try io.create() self.consoleSocket = consoleSocket self.io = io self.state = Mutex(State()) } func start() async throws -> Int32 { try self.state.withLock { guard case .initial = $0.state else { throw ContainerizationError( .invalidState, message: "container already started" ) } $0.state = .creating } log.info( "starting runc process", metadata: [ "id": "\(id)" ]) let pidFilePath = self.bundle.path.appendingPathComponent("runc-pid").path let runcIO = self.io.getIO() let opts: CreateOpts if let consoleSocket { opts = CreateOpts( pidFile: pidFilePath, consoleSocket: consoleSocket.path, io: runcIO ) } else { opts = CreateOpts( pidFile: pidFilePath, io: runcIO ) } guard let pidInt = try await self.runc.create( id: self.id, bundle: self.bundle.path.path, opts: opts ) else { throw ContainerizationError( .internalError, message: "runc create did not return a PID" ) } let pid = Int32(pidInt) self.log.info( "container created", metadata: [ "pid": "\(pid)" ]) // Close the pipe ends we gave to runc now that it has inherited them // and attach console if in terminal mode if self.terminal, let consoleSocket = self.consoleSocket { self.log.info("waiting for console FD from runc") let ptyFd = try consoleSocket.receiveMaster() self.log.info( "received PTY FD from runc, attaching", metadata: [ "id": "\(self.id)" ]) try self.io.closeAfterExec() try self.io.attachConsole(fd: ptyFd) } else { try self.io.closeAfterExec() } try await self.runc.start(id: self.id) self.state.withLock { $0.state = .running(pid: pid) } self.log.info( "started runc process", metadata: [ "pid": "\(pid)", "id": "\(self.id)", ]) return pid } func setExit(_ status: Int32) { self.state.withLock { self.log.info( "runc process exit", metadata: [ "status": "\(status)" ]) let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now) $0.state = .exited(exitStatus) do { try self.io.close() } catch { self.log.error("failed to close I/O for process: \(error)") } for waiter in $0.waiters { waiter.resume(returning: exitStatus) } self.log.debug("\($0.waiters.count) runc process waiters signaled") $0.waiters.removeAll() } } func wait() async -> ContainerExitStatus { await withCheckedContinuation { cont in self.state.withLock { if case .exited(let exitStatus) = $0.state { cont.resume(returning: exitStatus) return } $0.waiters.append(cont) } } } func kill(_ signal: Int32) async throws { self.log.info("sending signal \(signal) to runc container \(id)") try await self.runc.kill(id: self.id, signal: signal) } func resize(size: Terminal.Size) throws { try self.state.withLock { if case .exited = $0.state { return } try self.io.resize(size: size) } } func closeStdin() throws { try self.io.closeStdin() } func delete() async throws { let shouldDelete = self.state.withLock { state -> Bool in switch state.state { case .initial, .creating: return false default: return true } } guard shouldDelete else { log.info("container was never created, skipping delete") return } log.info("deleting runc container", metadata: ["id": "\(id)"]) try await self.runc.delete( id: self.id, opts: DeleteOpts(force: true) ) if let consoleSocket = self.consoleSocket { try consoleSocket.close() } } } // MARK: - RuncTerminalIO final class RuncTerminalIO: RuncProcess.IO & Sendable { private struct State { var stdinSocket: Socket? var stdoutSocket: Socket? var stdin: IOPair? var stdout: IOPair? var terminal: Terminal? } private let log: Logger? private let hostStdio: HostStdio private let state: Mutex init( stdio: HostStdio, log: Logger? ) throws { self.hostStdio = stdio self.log = log self.state = Mutex(State()) } func resize(size: Terminal.Size) throws { try self.state.withLock { if let terminal = $0.terminal { try terminal.resize(size: size) } } } func create() throws { try self.state.withLock { if let stdinPort = self.hostStdio.stdin { let type = VsockType( port: stdinPort, cid: VsockType.hostCID ) let stdinSocket = try Socket(type: type, closeOnDeinit: false) try stdinSocket.connect() $0.stdinSocket = stdinSocket } if let stdoutPort = self.hostStdio.stdout { let type = VsockType( port: stdoutPort, cid: VsockType.hostCID ) let stdoutSocket = try Socket(type: type, closeOnDeinit: false) try stdoutSocket.connect() $0.stdoutSocket = stdoutSocket } } } func getIO() -> Runc.IO { // Terminal mode doesn't pass pipes to runc, it uses the console socket .inherit } func closeAfterExec() throws { // No pipes to close in terminal mode } func attachConsole(fd: Int32) throws { try self.state.withLock { let term = try Terminal(descriptor: fd, setInitState: false) $0.terminal = term if let stdinSocket = $0.stdinSocket { let pair = IOPair( readFrom: stdinSocket, writeTo: term, reason: "RuncTerminalIO stdin", logger: log ) try pair.relay(ignoreHup: true) $0.stdin = pair } if let stdoutSocket = $0.stdoutSocket { let pair = IOPair( readFrom: term, writeTo: stdoutSocket, reason: "RuncTerminalIO stdout", logger: log ) try pair.relay(ignoreHup: true) $0.stdout = pair } } } func close() throws { self.state.withLock { if let stdin = $0.stdin { stdin.close() $0.stdin = nil } if let stdout = $0.stdout { stdout.close() $0.stdout = nil } $0.terminal = nil } } func closeStdin() throws { self.state.withLock { if let stdin = $0.stdin { stdin.close() $0.stdin = nil } } } } // MARK: - RuncStandardIO final class RuncStandardIO: RuncProcess.IO & Sendable { private struct State { var stdin: IOPair? var stdout: IOPair? var stderr: IOPair? var stdinPipe: Pipe? var stdoutPipe: Pipe? var stderrPipe: Pipe? } private let log: Logger? private let hostStdio: HostStdio private let state: Mutex init( stdio: HostStdio, log: Logger? ) { self.hostStdio = stdio self.log = log self.state = Mutex(State()) } // NOP for non-terminal func attachConsole(fd: Int32) throws {} func create() throws { try self.state.withLock { if let stdinPort = self.hostStdio.stdin { let inPipe = Pipe() $0.stdinPipe = inPipe let type = VsockType( port: stdinPort, cid: VsockType.hostCID ) let stdinSocket = try Socket(type: type, closeOnDeinit: false) try stdinSocket.connect() let pair = IOPair( readFrom: stdinSocket, writeTo: inPipe.fileHandleForWriting, reason: "RuncStandardIO stdin", logger: log ) $0.stdin = pair try pair.relay() } if let stdoutPort = self.hostStdio.stdout { let outPipe = Pipe() $0.stdoutPipe = outPipe let type = VsockType( port: stdoutPort, cid: VsockType.hostCID ) let stdoutSocket = try Socket(type: type, closeOnDeinit: false) try stdoutSocket.connect() let pair = IOPair( readFrom: outPipe.fileHandleForReading, writeTo: stdoutSocket, reason: "RuncStandardIO stdout", logger: log ) $0.stdout = pair try pair.relay() } if let stderrPort = self.hostStdio.stderr { let errPipe = Pipe() $0.stderrPipe = errPipe let type = VsockType( port: stderrPort, cid: VsockType.hostCID ) let stderrSocket = try Socket(type: type, closeOnDeinit: false) try stderrSocket.connect() let pair = IOPair( readFrom: errPipe.fileHandleForReading, writeTo: stderrSocket, reason: "RuncStandardIO stderr", logger: log ) $0.stderr = pair try pair.relay() } } } func getIO() -> Runc.IO { self.state.withLock { Runc.IO( stdin: $0.stdinPipe?.fileHandleForReading, stdout: $0.stdoutPipe?.fileHandleForWriting, stderr: $0.stderrPipe?.fileHandleForWriting ) } } func closeAfterExec() throws { try self.state.withLock { // Close the pipe ends we gave to runc (the child inherited them) if let stdinPipe = $0.stdinPipe { try stdinPipe.fileHandleForReading.close() $0.stdinPipe = nil } if let stdoutPipe = $0.stdoutPipe { try stdoutPipe.fileHandleForWriting.close() $0.stdoutPipe = nil } if let stderrPipe = $0.stderrPipe { try stderrPipe.fileHandleForWriting.close() $0.stderrPipe = nil } } } func resize(size: Terminal.Size) throws { throw ContainerizationError(.unsupported, message: "resize not supported for standard IO") } func close() throws { self.state.withLock { if let stdin = $0.stdin { stdin.close() $0.stdin = nil } if let stdout = $0.stdout { stdout.close() $0.stdout = nil } if let stderr = $0.stderr { stderr.close() $0.stderr = nil } } } func closeStdin() throws { self.state.withLock { if let stdin = $0.stdin { stdin.close() $0.stdin = nil } } } } #endif