diff --git a/vminitd/Sources/vminitd/CommandRunner.swift b/vminitd/Sources/vminitd/CommandRunner.swift new file mode 100644 index 00000000..db69813c --- /dev/null +++ b/vminitd/Sources/vminitd/CommandRunner.swift @@ -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)) + } + } + } +} diff --git a/vminitd/Sources/vminitd/ProcessSupervisor.swift b/vminitd/Sources/vminitd/ProcessSupervisor.swift index a61fdb9f..1c79890e 100644 --- a/vminitd/Sources/vminitd/ProcessSupervisor.swift +++ b/vminitd/Sources/vminitd/ProcessSupervisor.swift @@ -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() diff --git a/vminitd/Sources/vminitd/Runc/ConsoleSocket.swift b/vminitd/Sources/vminitd/Runc/ConsoleSocket.swift new file mode 100644 index 00000000..db54aff9 --- /dev/null +++ b/vminitd/Sources/vminitd/Runc/ConsoleSocket.swift @@ -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) diff --git a/vminitd/Sources/vminitd/Runc/Runc.swift b/vminitd/Sources/vminitd/Runc/Runc.swift new file mode 100644 index 00000000..2408e439 --- /dev/null +++ b/vminitd/Sources/vminitd/Runc/Runc.swift @@ -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( + 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? + } +}