mirror of
https://github.com/apple/container.git
synced 2026-09-12 10:45:42 +00:00
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:
@@ -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)
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user