//===----------------------------------------------------------------------===// // 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 ContainerizationOCI import ContainerizationOS import Foundation /// Log format for runc output enum LogFormat: String, Sendable { case json case text } /// Configuration and client for interacting with the runc binary struct Runc: Sendable { /// IO configuration for runc operations struct IO: Sendable { var stdin: FileHandle? var stdout: FileHandle? var stderr: FileHandle? init( stdin: FileHandle? = nil, stdout: FileHandle? = nil, stderr: FileHandle? = nil ) { self.stdin = stdin self.stdout = stdout self.stderr = stderr } static let inherit = IO() } /// Path to the runc binary var command: String /// Root directory for container state var root: String? /// Enable debug output var debug: Bool /// Path to log file var log: String? /// Format for log output var logFormat: LogFormat? /// Signal to send when parent process dies var pdeathSignal: Int32? /// Set process group ID var setpgid: Bool /// Path to criu binary for checkpoint/restore var criu: String? /// Use systemd cgroup manager var systemdCgroup: Bool /// Enable rootless mode var rootless: Bool /// Additional arguments to pass to runc var extraArgs: [String] /// Command runner to use instead of direct wait4 (for PID 1 environments with reapers) var commandRunner: (any CommandRunner)? init( command: String = "runc", root: String? = nil, debug: Bool = false, log: String? = nil, logFormat: LogFormat? = nil, pdeathSignal: Int32? = nil, setpgid: Bool = false, criu: String? = nil, systemdCgroup: Bool = false, rootless: Bool = false, extraArgs: [String] = [], commandRunner: (any CommandRunner)? = nil ) { self.command = command self.root = root self.debug = debug self.log = log self.logFormat = logFormat self.pdeathSignal = pdeathSignal self.setpgid = setpgid self.criu = criu self.systemdCgroup = systemdCgroup self.rootless = rootless self.extraArgs = extraArgs self.commandRunner = commandRunner } } /// Options for creating a container struct CreateOpts: Sendable { /// Path to file to write container PID var pidFile: String? /// Path to console socket for terminal access var consoleSocket: String? /// Detach from the container process var detach: Bool /// Do not use pivot_root to change root var noPivot: Bool /// Do not create a new session var noNewKeyring: Bool /// Additional file descriptors to pass to the container var extraFiles: [FileHandle] /// IO configuration for the runc process var io: Runc.IO init( pidFile: String? = nil, consoleSocket: String? = nil, detach: Bool = false, noPivot: Bool = false, noNewKeyring: Bool = false, extraFiles: [FileHandle] = [], io: Runc.IO = .inherit ) { self.pidFile = pidFile self.consoleSocket = consoleSocket self.detach = detach self.noPivot = noPivot self.noNewKeyring = noNewKeyring self.extraFiles = extraFiles self.io = io } } /// Options for executing a process in a container struct ExecOpts: Sendable { /// Path to file to write process PID var pidFile: String? /// Path to console socket for terminal access var consoleSocket: String? /// Detach from the process var detach: Bool /// Path to process.json file var processPath: String? /// IO configuration for the runc process var io: Runc.IO init( pidFile: String? = nil, consoleSocket: String? = nil, detach: Bool = false, processPath: String? = nil, io: Runc.IO = .inherit ) { self.pidFile = pidFile self.consoleSocket = consoleSocket self.detach = detach self.processPath = processPath self.io = io } } /// Options for deleting a container struct DeleteOpts: Sendable { /// Force deletion of a running container var force: Bool init(force: Bool = false) { self.force = force } } /// Options for restoring a container from checkpoint struct RestoreOpts: Sendable { /// Path to file to write container PID var pidFile: String? /// Path to console socket for terminal access var consoleSocket: String? /// Detach from the container process var detach: Bool /// Do not use pivot_root to change root var noPivot: Bool /// Do not create a new session var noNewKeyring: Bool /// Path to checkpoint image var imagePath: String? /// Path to parent checkpoint var parentPath: String? /// Work directory for CRIU var workPath: String? init( pidFile: String? = nil, consoleSocket: String? = nil, detach: Bool = false, noPivot: Bool = false, noNewKeyring: Bool = false, imagePath: String? = nil, parentPath: String? = nil, workPath: String? = nil ) { self.pidFile = pidFile self.consoleSocket = consoleSocket self.detach = detach self.noPivot = noPivot self.noNewKeyring = noNewKeyring self.imagePath = imagePath self.parentPath = parentPath self.workPath = workPath } } /// Container information returned from list operation struct Container: Sendable, Codable { let id: String let pid: Int let status: String let bundle: String let rootfs: String let created: Date let annotations: [String: String]? enum CodingKeys: String, CodingKey { case id case pid case status case bundle case rootfs case created case annotations } } extension Runc { enum Error: Swift.Error, CustomStringConvertible { case invalidJSON(String) case commandFailed(Int32, String) case invalidPidFile(String) var description: String { switch self { case .invalidJSON(let detail): return "invalid JSON: \(detail)" case .commandFailed(let status, let output): return "command failed with status \(status): \(output)" case .invalidPidFile(let path): return "invalid or missing PID file: \(path)" } } } } // MARK: - Command Building and Execution extension Runc { /// Build base arguments for runc command func baseArgs() -> [String] { var args: [String] = [] if let root = root { args += ["--root", root] } if debug { args.append("--debug") } if let log = log { args += ["--log", log] } if let logFormat = logFormat { args += ["--log-format", logFormat.rawValue] } if systemdCgroup { args.append("--systemd-cgroup") } if rootless { args.append("--rootless") } args += extraArgs return args } /// Execute a runc command and return the output func execute( args: [String], stdin: FileHandle? = nil, stdout: FileHandle? = nil, stderr: FileHandle? = nil, extraFiles: [FileHandle] = [], directory: String? = nil ) async throws -> (status: Int32, output: Data) { var cmd = Command( command, arguments: args, directory: directory, extraFiles: extraFiles ) // Setup IO let outPipe = Pipe() cmd.stdin = stdin cmd.stdout = stdout ?? outPipe.fileHandleForWriting cmd.stderr = stderr ?? outPipe.fileHandleForWriting if let pdeathSignal = pdeathSignal { cmd.attrs.pdeathSignal = pdeathSignal } if setpgid { cmd.attrs.setPGroup = true } let exitStatus: Int32 if let runner = commandRunner { let subscription = try runner.start(&cmd) exitStatus = try await runner.wait(cmd, subscription: subscription) } else { try cmd.start() exitStatus = try cmd.wait() } var output = Data() if stdout == nil { try? outPipe.fileHandleForWriting.close() output = try outPipe.fileHandleForReading.readToEnd() ?? Data() } return (exitStatus, output) } /// Execute command and parse JSON output func executeJSON( args: [String], directory: String? = nil ) async throws -> T { let (status, output) = try await execute(args: args, directory: directory) guard status == 0 else { let errorOutput = String(data: output, encoding: .utf8) ?? "" throw Error.commandFailed(status, errorOutput) } do { return try JSONDecoder().decode(T.self, from: output) } catch { let outputStr = String(data: output, encoding: .utf8) ?? "" throw Error.invalidJSON("failed to decode: \(error), output: \(outputStr)") } } /// Execute command without capturing output func executeVoid( args: [String], stdin: FileHandle? = nil, stdout: FileHandle? = nil, stderr: FileHandle? = nil, extraFiles: [FileHandle] = [], directory: String? = nil ) async throws { let (status, output) = try await execute( args: args, stdin: stdin, stdout: stdout, stderr: stderr, extraFiles: extraFiles, directory: directory ) guard status == 0 else { let errorOutput = String(data: output, encoding: .utf8) ?? "" throw Error.commandFailed(status, errorOutput) } } /// Read PID from a file func readPidFile(_ path: String) throws -> Int { guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), let pidString = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), let pid = Int(pidString) else { throw Error.invalidPidFile(path) } return pid } } extension Runc { /// Create a container func create( id: String, bundle: String, opts: CreateOpts = CreateOpts() ) async throws -> Int? { var args = baseArgs() + ["create"] if let pidFile = opts.pidFile { args += ["--pid-file", pidFile] } if let consoleSocket = opts.consoleSocket { args += ["--console-socket", consoleSocket] } if opts.detach { args.append("--detach") } if opts.noPivot { args.append("--no-pivot") } if opts.noNewKeyring { args.append("--no-new-keyring") } args += ["--bundle", bundle, id] try await executeVoid( args: args, stdin: opts.io.stdin, stdout: opts.io.stdout, stderr: opts.io.stderr, extraFiles: opts.extraFiles, directory: bundle ) // Read PID if pidFile was specified if let pidFile = opts.pidFile { return try readPidFile(pidFile) } return nil } /// Start a container func start(id: String) async throws { let args = baseArgs() + ["start", id] try await executeVoid(args: args) } /// Run a container (create + start) func run( id: String, bundle: String, opts: CreateOpts = CreateOpts() ) async throws -> Int? { var args = baseArgs() + ["run"] if let pidFile = opts.pidFile { args += ["--pid-file", pidFile] } if let consoleSocket = opts.consoleSocket { args += ["--console-socket", consoleSocket] } if opts.detach { args.append("--detach") } if opts.noPivot { args.append("--no-pivot") } if opts.noNewKeyring { args.append("--no-new-keyring") } args += ["--bundle", bundle, id] try await executeVoid( args: args, stdin: opts.io.stdin, stdout: opts.io.stdout, stderr: opts.io.stderr, extraFiles: opts.extraFiles, directory: bundle ) // Read PID if pidFile was specified if let pidFile = opts.pidFile { return try readPidFile(pidFile) } return nil } /// Delete a container func delete(id: String, opts: DeleteOpts = DeleteOpts()) async throws { var args = baseArgs() + ["delete"] if opts.force { args.append("--force") } args.append(id) try await executeVoid(args: args) } /// Send a signal to a container func kill(id: String, signal: Int32, all: Bool = false) async throws { var args = baseArgs() + ["kill"] if all { args.append("--all") } args += [id, String(signal)] try await executeVoid(args: args) } /// Pause a container func pause(id: String) async throws { let args = baseArgs() + ["pause", id] try await executeVoid(args: args) } /// Resume a paused container func resume(id: String) async throws { let args = baseArgs() + ["resume", id] try await executeVoid(args: args) } /// Execute a process in a running container func exec( id: String, processSpec: String, opts: ExecOpts = ExecOpts() ) async throws -> Int? { var args = baseArgs() + ["exec"] if let pidFile = opts.pidFile { args += ["--pid-file", pidFile] } if let consoleSocket = opts.consoleSocket { args += ["--console-socket", consoleSocket] } if opts.detach { args.append("--detach") } if let processPath = opts.processPath { args += ["--process", processPath] } args += [id, processSpec] try await executeVoid( args: args, stdin: opts.io.stdin, stdout: opts.io.stdout, stderr: opts.io.stderr ) // Read PID if pidFile was specified if let pidFile = opts.pidFile { return try readPidFile(pidFile) } return nil } /// Update container resources func update(id: String, resources: String) async throws { let args = baseArgs() + ["update", "--resources", resources, id] try await executeVoid(args: args) } /// Checkpoint a container func checkpoint( id: String, imagePath: String, leaveRunning: Bool = false, workPath: String? = nil ) async throws { var args = baseArgs() + ["checkpoint"] if leaveRunning { args.append("--leave-running") } if let workPath = workPath { args += ["--work-path", workPath] } args += ["--image-path", imagePath, id] try await executeVoid(args: args) } /// Restore a container from checkpoint func restore( id: String, bundle: String, opts: RestoreOpts = RestoreOpts() ) async throws -> Int? { var args = baseArgs() + ["restore"] if let pidFile = opts.pidFile { args += ["--pid-file", pidFile] } if let consoleSocket = opts.consoleSocket { args += ["--console-socket", consoleSocket] } if opts.detach { args.append("--detach") } if opts.noPivot { args.append("--no-pivot") } if opts.noNewKeyring { args.append("--no-new-keyring") } if let imagePath = opts.imagePath { args += ["--image-path", imagePath] } if let parentPath = opts.parentPath { args += ["--parent-path", parentPath] } if let workPath = opts.workPath { args += ["--work-path", workPath] } args += ["--bundle", bundle, id] try await executeVoid(args: args, directory: bundle) if let pidFile = opts.pidFile { return try readPidFile(pidFile) } return nil } } // MARK: - List and State Operations extension Runc { /// List all containers func list() async throws -> [Container] { let args = baseArgs() + ["list", "--format", "json"] let containers: [Container] = try await executeJSON(args: args) return containers } /// Get state of a specific container func state(id: String) async throws -> ContainerizationOCI.State { let args = baseArgs() + ["state", id] let state: ContainerizationOCI.State = try await executeJSON(args: args) return state } /// List process IDs in a container func ps(id: String) async throws -> [Int] { let args = baseArgs() + ["ps", "--format", "json", id] let (status, output) = try await execute(args: args) guard status == 0 else { let errorOutput = String(data: output, encoding: .utf8) ?? "" throw Error.commandFailed(status, errorOutput) } // ps output is just an array of PIDs let pids = try JSONDecoder().decode([Int].self, from: output) return pids } /// Get version information func version() async throws -> String { let args = [command, "--version"] let (status, output) = try await execute(args: args) guard status == 0 else { let errorOutput = String(data: output, encoding: .utf8) ?? "" throw Error.commandFailed(status, errorOutput) } return String(data: output, encoding: .utf8) ?? "" } } // MARK: - Events extension Runc { /// Event from container runtime struct Event: Codable, Sendable { let type: String let id: String let stats: EventStats? enum CodingKeys: String, CodingKey { case type case id case stats } } /// Statistics in an event struct EventStats: Codable, Sendable { let cpu: CPUStats? let memory: MemoryStats? let pids: PIDStats? enum CodingKeys: String, CodingKey { case cpu case memory case pids } } struct CPUStats: Codable, Sendable { let usage: CPUUsage? let throttling: ThrottlingData? struct CPUUsage: Codable, Sendable { let total: UInt64? let percpu: [UInt64]? } struct ThrottlingData: Codable, Sendable { let periods: UInt64? let throttledPeriods: UInt64? let throttledTime: UInt64? } } struct MemoryStats: Codable, Sendable { let usage: MemoryUsage? let limit: UInt64? struct MemoryUsage: Codable, Sendable { let usage: UInt64? let max: UInt64? } } struct PIDStats: Codable, Sendable { let current: UInt64? let limit: UInt64? } } #endif