Add runc process launch wrapper (#333)

Somewhat simple and missing events and some other bits I believe, but
it's a start. Maybe should use the async subprocess type as well instead
of our homegrown Command.
This commit is contained in:
Danny Canter
2025-10-16 14:24:25 -04:00
committed by GitHub
parent 8a34763e40
commit 21d0c799b8
4 changed files with 972 additions and 2 deletions
+104
View File
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
import Synchronization
struct ProcessSubscription: Sendable {
fileprivate let id: UUID
}
/// Protocol for running commands and waiting for their exit
protocol CommandRunner: Sendable {
func start(_ cmd: inout Command) throws -> ProcessSubscription
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32
}
struct DirectCommandRunner: CommandRunner {
func start(_ cmd: inout Command) throws -> ProcessSubscription {
try cmd.start()
return ProcessSubscription(id: UUID())
}
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32 {
var rus = rusage()
var ws = Int32()
let result = wait4(cmd.pid, &ws, 0, &rus)
guard result == cmd.pid else {
throw POSIXError(.init(rawValue: errno)!)
}
return Command.toExitStatus(ws)
}
}
final class ReaperCommandRunner: CommandRunner, Sendable {
private struct Subscriber {
let continuation: AsyncStream<(pid: pid_t, status: Int32)>.Continuation
let stream: AsyncStream<(pid: pid_t, status: Int32)>
}
private let subscribers: Mutex<[UUID: Subscriber]> = Mutex([:])
func start(_ cmd: inout Command) throws -> ProcessSubscription {
// Subscribe before starting to avoid missing fast exits
let id = UUID()
let (stream, continuation) = AsyncStream<(pid: pid_t, status: Int32)>.makeStream()
subscribers.withLock { subscribers in
subscribers[id] = Subscriber(continuation: continuation, stream: stream)
}
try cmd.start()
return ProcessSubscription(id: id)
}
func wait(_ cmd: Command, subscription: ProcessSubscription) async throws -> Int32 {
let pid = cmd.pid
let id = subscription.id
defer {
subscribers.withLock { subscribers in
subscribers[id]?.continuation.finish()
subscribers.removeValue(forKey: id)
}
}
// Get the stream from the subscriber
guard let stream = subscribers.withLock({ $0[id]?.stream }) else {
throw POSIXError(.ECHILD)
}
for await (exitPid, status) in stream {
if exitPid == pid {
return status
}
}
throw POSIXError(.ECHILD)
}
/// Broadcast exit to all subscribers
func notifyExit(pid: pid_t, status: Int32) {
subscribers.withLock { subscribers in
for subscriber in subscribers.values {
subscriber.continuation.yield((pid, status))
}
}
}
}
@@ -19,10 +19,13 @@ import Foundation
import Logging
actor ProcessSupervisor {
let poller: Epoll
private let queue: DispatchQueue
// `DispatchSourceSignal` is thread-safe.
private nonisolated(unsafe) let source: DispatchSourceSignal
private var processes = [ManagedProcess]()
private let reaperCommandRunner = ReaperCommandRunner()
var log: Logger?
@@ -32,8 +35,6 @@ actor ProcessSupervisor {
static let `default` = ProcessSupervisor()
let poller: Epoll
private init() {
let queue = DispatchQueue(label: "process-supervisor")
self.source = DispatchSource.makeSignalSource(signal: SIGCHLD, queue: queue)
@@ -64,6 +65,13 @@ actor ProcessSupervisor {
let exited = Reaper.reap()
self.log?.debug("finished wait4 of \(exited.count) processes")
// Notify runc waiters
// NOTE: Runc/OCI runtimes are not hooked up at the moment so this is
// a nop, but ManagedProcess will be transitioned to this model.
for (pid, status) in exited {
reaperCommandRunner.notifyExit(pid: pid, status: status)
}
self.log?.debug("checking for exit of managed process", metadata: ["exits": "\(exited)", "processes": "\(processes.count)"])
let exitedProcesses = self.processes.filter { proc in
exited.contains { pid, _ in
@@ -106,6 +114,13 @@ actor ProcessSupervisor {
}
}
/// Get a Runc instance configured with the reaper command runner
func getRuncWithReaper(_ base: Runc = Runc()) -> Runc {
var runc = base
runc.commandRunner = reaperCommandRunner
return runc
}
deinit {
self.log?.info("process supervisor deinit")
source.cancel()
@@ -0,0 +1,95 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
#if os(Linux)
/// A Unix socket for receiving PTY master file descriptors from runc
public final class ConsoleSocket: Sendable {
private let socket: Socket
private let socketPath: String
private let shouldRemove: Bool
/// The path to the console socket
public var path: String { socketPath }
/// Create a new console socket at the specified path
public init(path: String) throws {
let absPath = path.starts(with: "/") ? path : FileManager.default.currentDirectoryPath + "/" + path
self.socketPath = absPath
let pathURL = URL(fileURLWithPath: absPath)
let dir = pathURL.deletingLastPathComponent().path
try FileManager.default.createDirectory(
atPath: dir,
withIntermediateDirectories: true,
attributes: nil
)
let socketType = try UnixType(path: absPath, unlinkExisting: true)
self.socket = try Socket(type: socketType)
self.shouldRemove = false
try socket.listen()
}
/// Create a temporary console socket in the runtime directory
public static func temporary() throws -> ConsoleSocket {
let tmpDir = "/tmp"
let socketDir = tmpDir + "/runc-console-\(UUID().uuidString)"
let socketPath = socketDir + "/console.sock"
try FileManager.default.createDirectory(
atPath: socketDir,
withIntermediateDirectories: true,
attributes: nil
)
let socket = try ConsoleSocket(path: socketPath)
return socket
}
/// Receive the PTY master file descriptor from runc
public func receiveMaster() throws -> FileHandle {
let connection = try socket.accept()
defer { try? connection.close() }
return try connection.receiveFileDescriptor()
}
/// Close the socket and optionally remove the socket file
public func close() throws {
try socket.close()
if shouldRemove {
try? FileManager.default.removeItem(atPath: socketPath)
let pathURL = URL(fileURLWithPath: socketPath)
let dir = pathURL.deletingLastPathComponent().path
if dir.contains("runc-console-") {
try? FileManager.default.removeItem(atPath: dir)
}
}
}
deinit {
try? close()
}
}
#endif // os(Linux)
+756
View File
@@ -0,0 +1,756 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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.
//===----------------------------------------------------------------------===//
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 {
/// 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]
init(
pidFile: String? = nil,
consoleSocket: String? = nil,
detach: Bool = false,
noPivot: Bool = false,
noNewKeyring: Bool = false,
extraFiles: [FileHandle] = []
) {
self.pidFile = pidFile
self.consoleSocket = consoleSocket
self.detach = detach
self.noPivot = noPivot
self.noNewKeyring = noNewKeyring
self.extraFiles = extraFiles
}
}
/// 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?
init(
pidFile: String? = nil,
consoleSocket: String? = nil,
detach: Bool = false,
processPath: String? = nil
) {
self.pidFile = pidFile
self.consoleSocket = consoleSocket
self.detach = detach
self.processPath = processPath
}
}
/// 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
// FIXME: pdeathSignal handling if Command supported it.
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<T: Decodable>(
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,
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,
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(),
stdin: FileHandle? = nil,
stdout: FileHandle? = nil,
stderr: FileHandle? = nil
) 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: stdin,
stdout: stdout,
stderr: 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?
}
}