chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import Cgroup
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
#if os(Linux)
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
import LCShim
|
||||
#endif
|
||||
|
||||
public struct AgentCommand: AsyncParsableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "agent",
|
||||
abstract: "Run the vminitd agent daemon"
|
||||
)
|
||||
|
||||
private static let foregroundEnvVar = "FOREGROUND"
|
||||
public static let vsockPort = 1024
|
||||
|
||||
@OptionGroup var options: LogLevelOption
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Bootstrap the vminitd environment and create an Initd server.
|
||||
/// Handles mounts, cgroups, memory monitoring, and all pre-serve setup.
|
||||
public static func bootstrap(options: LogLevelOption) async throws -> Initd {
|
||||
let log = makeLogger(label: "vminitd", level: options.resolvedLogLevel())
|
||||
try adjustLimits(log)
|
||||
|
||||
// When running under debug mode, launch vminitd as a sub process of pid1
|
||||
// so that we get a chance to collect better logs and errors before pid1 exists
|
||||
// and the kernel panics.
|
||||
#if DEBUG
|
||||
log.info("DEBUG mode active, checking FOREGROUND env var")
|
||||
let environment = ProcessInfo.processInfo.environment
|
||||
let foreground = environment[foregroundEnvVar]
|
||||
log.info("checking for shim var \(foregroundEnvVar)=\(String(describing: foreground))")
|
||||
|
||||
if foreground == nil {
|
||||
try runInForeground(log, logLevel: options.logLevel)
|
||||
_exit(0)
|
||||
}
|
||||
|
||||
log.info("FOREGROUND is set, running as subprocess, setting subreaper")
|
||||
// Since we are not running as pid1 in this mode we must set ourselves
|
||||
// as a subreaper so that all child processes are reaped by us and not
|
||||
// passed onto our parent.
|
||||
CZ_set_sub_reaper()
|
||||
#endif
|
||||
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
|
||||
log.info("vminitd booting", metadata: versionMetadata())
|
||||
|
||||
// Set of mounts necessary to be mounted prior to taking any RPCs.
|
||||
// 1. /proc as the sysctl rpc wouldn't make sense if it wasn't there (NOTE: This is done before this method
|
||||
// due to Swift seemingly requiring /proc to be present for the async runtime to spin up).
|
||||
// 2. /run as that is where we store container state.
|
||||
// 3. /sys as we need it for /sys/fs/cgroup
|
||||
// 4. /sys/fs/cgroup to add the agent to a cgroup, as well as containers later.
|
||||
let mounts = [
|
||||
ContainerizationOS.Mount(
|
||||
type: "tmpfs",
|
||||
source: "tmpfs",
|
||||
target: "/run",
|
||||
options: []
|
||||
),
|
||||
ContainerizationOS.Mount(
|
||||
type: "sysfs",
|
||||
source: "sysfs",
|
||||
target: "/sys",
|
||||
options: []
|
||||
),
|
||||
ContainerizationOS.Mount(
|
||||
type: "cgroup2",
|
||||
source: "none",
|
||||
target: "/sys/fs/cgroup",
|
||||
options: []
|
||||
),
|
||||
]
|
||||
|
||||
for mnt in mounts {
|
||||
log.info("mounting \(mnt.target)")
|
||||
|
||||
try mnt.mount(createWithPerms: 0o755)
|
||||
}
|
||||
try Binfmt.mount()
|
||||
|
||||
let cgManager = Cgroup2Manager(
|
||||
group: URL(filePath: "/vminitd"),
|
||||
logger: log
|
||||
)
|
||||
try cgManager.create()
|
||||
try cgManager.toggleAllAvailableControllers(enable: true)
|
||||
|
||||
// Set memory.high threshold to 80 MiB
|
||||
let high: UInt64 = 80 * 1024 * 1024
|
||||
// Set memory.low to 50 MiB to avoid reclaiming vminitd's memory
|
||||
let low: UInt64 = 50 * 1024 * 1024
|
||||
|
||||
try cgManager.setMemoryHigh(bytes: high)
|
||||
try cgManager.setMemoryLow(bytes: low)
|
||||
try cgManager.addProcess(pid: getpid())
|
||||
|
||||
let memoryMonitor = try MemoryMonitor(
|
||||
cgroupManager: cgManager,
|
||||
threshold: high,
|
||||
logger: log
|
||||
) { [log] (currentUsage, highMark) in
|
||||
log.warning(
|
||||
"vminitd memory threshold exceeded",
|
||||
metadata: [
|
||||
"threshold_bytes": "\(high)",
|
||||
"current_bytes": "\(currentUsage)",
|
||||
"high_events_total": "\(highMark)",
|
||||
])
|
||||
}
|
||||
|
||||
let t = Thread { [log] in
|
||||
do {
|
||||
try memoryMonitor.run()
|
||||
} catch {
|
||||
log.error("memory monitor failed: \(error)")
|
||||
}
|
||||
}
|
||||
t.start()
|
||||
|
||||
let eg = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
let blockingPool = NIOThreadPool(numberOfThreads: 2)
|
||||
blockingPool.start()
|
||||
return Initd(log: log, group: eg, blockingPool: blockingPool)
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let server = try await Self.bootstrap(options: options)
|
||||
|
||||
do {
|
||||
server.log.info("serving vminitd API")
|
||||
try await server.serve(port: Self.vsockPort)
|
||||
server.log.info("vminitd API returned, syncing filesystems")
|
||||
|
||||
#if os(Linux)
|
||||
sync()
|
||||
#endif
|
||||
} catch {
|
||||
server.log.error("vminitd boot error \(error)")
|
||||
|
||||
#if os(Linux)
|
||||
sync()
|
||||
#endif
|
||||
|
||||
_exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runInForeground(_ log: Logger, logLevel: String) throws {
|
||||
log.info("running vminitd under pid1")
|
||||
|
||||
var command = Command("/sbin/vminitd", arguments: ["agent", "--log-level", logLevel])
|
||||
command.attrs = .init(setsid: true)
|
||||
command.stdin = .standardInput
|
||||
command.stdout = .standardOutput
|
||||
command.stderr = .standardError
|
||||
command.environment = ["\(foregroundEnvVar)=1"]
|
||||
|
||||
try command.start()
|
||||
let exitCode = try command.wait()
|
||||
log.info("child process exited with code: \(exitCode)")
|
||||
}
|
||||
|
||||
private static func adjustLimits(_ log: Logger) throws {
|
||||
let nrOpen = try String(contentsOfFile: "/proc/sys/fs/nr_open", encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let max = UInt64(nrOpen) else {
|
||||
throw POSIXError(.EINVAL)
|
||||
}
|
||||
log.debug("setting RLIMIT_NOFILE to \(max)")
|
||||
guard CZ_setrlimit(CZ_RLIMIT_NOFILE, max, max) == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,108 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
/// Exit status information for a container process
|
||||
struct ContainerExitStatus: Sendable {
|
||||
var exitCode: Int32
|
||||
var exitedAt: Date
|
||||
}
|
||||
|
||||
/// Protocol for managing container processes
|
||||
///
|
||||
/// This protocol abstracts the underlying container runtime implementation,
|
||||
/// allowing for different backends like vmexec or runc.
|
||||
protocol ContainerProcess: Sendable {
|
||||
/// Unique identifier for the container process
|
||||
var id: String { get }
|
||||
|
||||
/// Process ID of the running container (nil if not started)
|
||||
var pid: Int32? { get }
|
||||
|
||||
/// Start the container process
|
||||
/// - Returns: The process ID of the started container
|
||||
/// - Throws: If the process fails to start
|
||||
func start() async throws -> Int32
|
||||
|
||||
/// Wait for the container process to exit
|
||||
/// - Returns: Exit status information when the process exits
|
||||
func wait() async -> ContainerExitStatus
|
||||
|
||||
/// Send a signal to the container process
|
||||
/// - Parameter signal: The signal number to send
|
||||
/// - Throws: If the signal cannot be sent
|
||||
func kill(_ signal: Int32) async throws
|
||||
|
||||
/// Resize the terminal for the container process
|
||||
/// - Parameter size: The new terminal size
|
||||
/// - Throws: If the terminal cannot be resized or process doesn't have a terminal
|
||||
func resize(size: Terminal.Size) throws
|
||||
|
||||
/// Close stdin for the container process
|
||||
/// - Throws: If stdin cannot be closed
|
||||
func closeStdin() throws
|
||||
|
||||
/// Delete the container process and clean up resources
|
||||
/// - Throws: If cleanup fails
|
||||
func delete() async throws
|
||||
|
||||
/// Set the exit status of the process.
|
||||
func setExit(_ status: Int32)
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,26 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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)
|
||||
|
||||
struct HostStdio: Sendable {
|
||||
let stdin: UInt32?
|
||||
let stdout: UInt32?
|
||||
let stderr: UInt32?
|
||||
let terminal: Bool
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,32 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Socket: IOCloser {}
|
||||
|
||||
extension Terminal: IOCloser {
|
||||
var fileDescriptor: Int32 {
|
||||
self.handle.fileDescriptor
|
||||
}
|
||||
}
|
||||
|
||||
extension FileHandle: IOCloser {}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,37 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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)
|
||||
|
||||
protocol IOCloser: Sendable {
|
||||
var fileDescriptor: Int32 { get }
|
||||
|
||||
func close() throws
|
||||
}
|
||||
|
||||
struct UnownedIOCloser: IOCloser {
|
||||
private let inner: IOCloser
|
||||
|
||||
var fileDescriptor: Int32 { inner.fileDescriptor }
|
||||
|
||||
init(_ inner: IOCloser) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
func close() throws {}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,189 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
final class IOPair: Sendable {
|
||||
private let io: Mutex<IO>
|
||||
private let logger: Logger?
|
||||
private let reason: String
|
||||
|
||||
private struct IO {
|
||||
let from: IOCloser
|
||||
let to: IOCloser
|
||||
let buffer: UnsafeMutableBufferPointer<UInt8>
|
||||
var closed: Bool
|
||||
var registeredFd: Int32?
|
||||
|
||||
func drain() {
|
||||
let readFrom = OSFile(fd: from.fileDescriptor)
|
||||
let writeTo = OSFile(fd: to.fileDescriptor)
|
||||
|
||||
while true {
|
||||
let r = readFrom.read(buffer)
|
||||
if r.read > 0 {
|
||||
let view = UnsafeMutableBufferPointer(
|
||||
start: buffer.baseAddress,
|
||||
count: r.read
|
||||
)
|
||||
|
||||
let w = writeTo.write(view)
|
||||
if w.wrote != r.read {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch r.action {
|
||||
case .eof, .again, .error(_):
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutating func close(logger: Logger?) {
|
||||
if self.closed {
|
||||
return
|
||||
}
|
||||
|
||||
// Try and drain IO first.
|
||||
self.drain()
|
||||
|
||||
// Remove the fd from our global epoll instance first.
|
||||
if let fd = self.registeredFd {
|
||||
do {
|
||||
try ProcessSupervisor.default.unregisterFd(fd)
|
||||
} catch {
|
||||
logger?.error("failed to delete fd from epoll \(fd): \(error)")
|
||||
}
|
||||
self.registeredFd = nil
|
||||
}
|
||||
|
||||
do {
|
||||
try self.from.close()
|
||||
} catch {
|
||||
logger?.error("failed to close reader fd for IOPair: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
try self.to.close()
|
||||
} catch {
|
||||
logger?.error("failed to close writer fd for IOPair: \(error)")
|
||||
}
|
||||
self.buffer.deallocate()
|
||||
self.closed = true
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
readFrom: IOCloser,
|
||||
writeTo: IOCloser,
|
||||
reason: String,
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
let buffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: Int(getpagesize()))
|
||||
self.io = Mutex(
|
||||
IO(
|
||||
from: readFrom,
|
||||
to: writeTo,
|
||||
buffer: buffer,
|
||||
closed: false,
|
||||
registeredFd: nil
|
||||
))
|
||||
self.reason = reason
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
func relay(ignoreHup: Bool = false) throws {
|
||||
self.logger?.info("setting up relay for \(reason)")
|
||||
|
||||
let (readFromFd, writeToFd) = self.io.withLock { io in
|
||||
io.registeredFd = io.from.fileDescriptor
|
||||
return (io.from.fileDescriptor, io.to.fileDescriptor)
|
||||
}
|
||||
|
||||
let readFrom = OSFile(fd: readFromFd)
|
||||
let writeTo = OSFile(fd: writeToFd)
|
||||
|
||||
try ProcessSupervisor.default.registerFd(readFromFd, mask: .input) { mask in
|
||||
self.io.withLock { io in
|
||||
if io.closed {
|
||||
return
|
||||
}
|
||||
|
||||
if mask.isHangup && !mask.readyToRead {
|
||||
self.logger?.debug("received EPOLLHUP with no EPOLLIN")
|
||||
if !ignoreHup {
|
||||
io.close(logger: self.logger)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Loop so we drain fully.
|
||||
while true {
|
||||
let r = readFrom.read(io.buffer)
|
||||
if r.read > 0 {
|
||||
let view = UnsafeMutableBufferPointer(
|
||||
start: io.buffer.baseAddress,
|
||||
count: r.read
|
||||
)
|
||||
|
||||
let w = writeTo.write(view)
|
||||
if w.wrote != r.read {
|
||||
self.logger?.error("stopping relay: short write for stdio")
|
||||
io.close(logger: self.logger)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch r.action {
|
||||
case .error(let errno):
|
||||
self.logger?.error("failed with errno \(errno) while reading for fd \(readFromFd)")
|
||||
fallthrough
|
||||
case .eof:
|
||||
self.logger?.debug("closing relay for \(readFromFd)")
|
||||
io.close(logger: self.logger)
|
||||
return
|
||||
case .again:
|
||||
if mask.isHangup && !ignoreHup {
|
||||
self.logger?.error("received EPOLLHUP and EAGAIN exiting")
|
||||
self.close()
|
||||
}
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() {
|
||||
self.io.withLock { io in
|
||||
self.logger?.info("closing relay for \(reason)")
|
||||
io.close(logger: self.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,113 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import ContainerizationOS
|
||||
import LCShim
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _exit = Musl.exit
|
||||
private let _kill = Musl.kill
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _exit = Glibc.exit
|
||||
private let _kill = Glibc.kill
|
||||
#endif
|
||||
|
||||
/// A minimal init process that:
|
||||
/// - Spawns and monitors a child process
|
||||
/// - Forwards signals to the child
|
||||
/// - Reaps zombie processes
|
||||
/// - Exits with the child's exit code
|
||||
public struct InitCommand: ParsableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "init",
|
||||
abstract: "Run as a minimal init process"
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Send signals to the child's process group instead of just the child")
|
||||
var processGroup: Bool = false
|
||||
|
||||
@Argument(help: "The command to run")
|
||||
var command: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Arguments for the command")
|
||||
var arguments: [String] = []
|
||||
|
||||
/// Signals that should NOT be forwarded to the child.
|
||||
private static let ignoredSignals: Set<Int32> = [
|
||||
SIGCHLD, // We handle this for zombie reaping
|
||||
SIGFPE, SIGILL, SIGSEGV, SIGBUS, SIGABRT, SIGTRAP, SIGSYS, // Synchronous signals
|
||||
]
|
||||
|
||||
public mutating func run() throws {
|
||||
// If we're not PID 1, register as a child subreaper so orphaned
|
||||
// processes get reparented to us and we can reap them.
|
||||
if getpid() != 1 {
|
||||
CZ_set_sub_reaper()
|
||||
}
|
||||
|
||||
// Block all signals. We'll handle them synchronously via sigtimedwait
|
||||
var allSignals = sigset_t()
|
||||
sigfillset(&allSignals)
|
||||
sigprocmask(SIG_BLOCK, &allSignals, nil)
|
||||
|
||||
let resolvedCommand = Path.lookPath(command)?.path ?? command
|
||||
|
||||
var cmd = Command(resolvedCommand, arguments: arguments)
|
||||
cmd.stdin = .standardInput
|
||||
cmd.stdout = .standardOutput
|
||||
cmd.stderr = .standardError
|
||||
|
||||
cmd.attrs = .init(setPGroup: true, setForegroundPGroup: true, setSignalDefault: true)
|
||||
|
||||
try cmd.start()
|
||||
let childPid = cmd.pid
|
||||
let signalTarget = processGroup ? -childPid : childPid
|
||||
var timeout = timespec(tv_sec: 0, tv_nsec: 100_000_000)
|
||||
|
||||
// Handle signals and reap zombies
|
||||
var childExitStatus: Int32?
|
||||
while childExitStatus == nil {
|
||||
var siginfo = siginfo_t()
|
||||
let sig = sigtimedwait(&allSignals, &siginfo, &timeout)
|
||||
|
||||
if sig > 0 && !Self.ignoredSignals.contains(sig) {
|
||||
_ = _kill(signalTarget, sig)
|
||||
}
|
||||
|
||||
while true {
|
||||
var status: Int32 = 0
|
||||
let pid = waitpid(-1, &status, WNOHANG)
|
||||
if pid <= 0 {
|
||||
break
|
||||
}
|
||||
if pid == childPid {
|
||||
childExitStatus = Command.toExitStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_exit(childExitStatus ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,117 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
public struct LogLevelOption: ParsableArguments {
|
||||
@Option(name: .long, help: "Set the log level (trace, debug, info, notice, warning, error, critical)")
|
||||
public var logLevel: String = "info"
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(logLevel: String) {
|
||||
self.logLevel = logLevel
|
||||
}
|
||||
|
||||
public func resolvedLogLevel() -> Logger.Level {
|
||||
switch logLevel.lowercased() {
|
||||
case "trace":
|
||||
return .trace
|
||||
case "debug":
|
||||
return .debug
|
||||
case "info":
|
||||
return .info
|
||||
case "notice":
|
||||
return .notice
|
||||
case "warning":
|
||||
return .warning
|
||||
case "error":
|
||||
return .error
|
||||
case "critical":
|
||||
return .critical
|
||||
default:
|
||||
return .info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let _loggingBootstrapped = Mutex(false)
|
||||
private let _versionMetadata = Mutex<Logger.Metadata>([:])
|
||||
|
||||
/// Set the version metadata logged on boot.
|
||||
public func setVersionMetadata(_ metadata: Logger.Metadata) {
|
||||
_versionMetadata.withLock { $0 = metadata }
|
||||
}
|
||||
|
||||
func versionMetadata() -> Logger.Metadata {
|
||||
_versionMetadata.withLock { $0 }
|
||||
}
|
||||
|
||||
func makeLogger(label: String, level: Logger.Level) -> Logger {
|
||||
_loggingBootstrapped.withLock { bootstrapped in
|
||||
if !bootstrapped {
|
||||
LoggingSystem.bootstrap { label in StderrLogHandler(label: label) }
|
||||
bootstrapped = true
|
||||
}
|
||||
}
|
||||
var log = Logger(label: label)
|
||||
log.logLevel = level
|
||||
return log
|
||||
}
|
||||
|
||||
private struct StderrLogHandler: LogHandler {
|
||||
let label: String
|
||||
var logLevel: Logger.Level = .info
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { metadata[key] }
|
||||
set { metadata[key] = newValue }
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?,
|
||||
source: String, file: String, function: String, line: UInt
|
||||
) {
|
||||
var merged = self.metadata
|
||||
metadata?.forEach { merged[$0] = $1 }
|
||||
let metaStr = merged.isEmpty ? "" : " \(merged.map { "\($0): \($1)" }.sorted().joined(separator: ", "))"
|
||||
let ts = isoTimestamp()
|
||||
let data = "\(ts) \(level) \(label):\(metaStr) \(message)\n".data(using: .utf8) ?? Data()
|
||||
FileHandle.standardError.write(data)
|
||||
}
|
||||
|
||||
func isoTimestamp() -> String {
|
||||
let date = Date()
|
||||
var time = time_t(date.timeIntervalSince1970)
|
||||
var ms = Int(date.timeIntervalSince1970 * 1000) % 1000
|
||||
if ms < 0 { ms += 1000 }
|
||||
var tm = tm()
|
||||
gmtime_r(&time, &tm)
|
||||
let buf = withUnsafeTemporaryAllocation(of: CChar.self, capacity: 32) { ptr -> String in
|
||||
strftime(ptr.baseAddress!, 32, "%Y-%m-%dT%H:%M:%S", &tm)
|
||||
return String(cString: ptr.baseAddress!)
|
||||
}
|
||||
return String(format: "%@.%03dZ", buf, ms)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,286 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Cgroup
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
public actor ManagedContainer {
|
||||
public let id: String
|
||||
let initProcess: any ContainerProcess
|
||||
|
||||
private let cgroupManager: Cgroup2Manager
|
||||
private let log: Logger
|
||||
private let bundle: ContainerizationOCI.Bundle
|
||||
private let needsCgroupCleanup: Bool
|
||||
private var execs: [String: any ContainerProcess] = [:]
|
||||
|
||||
public var pid: Int32? {
|
||||
self.initProcess.pid
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
stdio: HostStdio,
|
||||
spec: ContainerizationOCI.Spec,
|
||||
ociRuntimePath: String? = nil,
|
||||
log: Logger
|
||||
) async throws {
|
||||
var cgroupsPath: String
|
||||
if let cgPath = spec.linux?.cgroupsPath {
|
||||
cgroupsPath = cgPath
|
||||
} else {
|
||||
cgroupsPath = "/container/\(id)"
|
||||
}
|
||||
|
||||
let bundle = try ContainerizationOCI.Bundle.create(
|
||||
path: Self.craftBundlePath(id: id),
|
||||
spec: spec
|
||||
)
|
||||
log.debug("created bundle with spec \(spec)")
|
||||
|
||||
let cgManager = Cgroup2Manager(
|
||||
group: URL(filePath: cgroupsPath),
|
||||
logger: log
|
||||
)
|
||||
try cgManager.create()
|
||||
|
||||
do {
|
||||
try cgManager.toggleAllAvailableControllers(enable: true)
|
||||
|
||||
let initProcess: any ContainerProcess
|
||||
|
||||
if let runtimePath = ociRuntimePath {
|
||||
// Use runc runtime
|
||||
let runc = ProcessSupervisor.default.getRuncWithReaper(
|
||||
Runc(
|
||||
command: runtimePath,
|
||||
root: "/run/runc"
|
||||
)
|
||||
)
|
||||
initProcess = try RuncProcess(
|
||||
id: id,
|
||||
stdio: stdio,
|
||||
bundle: bundle,
|
||||
runc: runc,
|
||||
log: log
|
||||
)
|
||||
self.needsCgroupCleanup = false
|
||||
log.info("created runc init process with runtime: \(runtimePath)")
|
||||
} else {
|
||||
// Use vmexec runtime
|
||||
initProcess = try ManagedProcess(
|
||||
id: id,
|
||||
stdio: stdio,
|
||||
bundle: bundle,
|
||||
owningPid: nil,
|
||||
log: log
|
||||
)
|
||||
self.needsCgroupCleanup = true
|
||||
log.info("created vmexec init process")
|
||||
}
|
||||
|
||||
self.cgroupManager = cgManager
|
||||
self.initProcess = initProcess
|
||||
self.id = id
|
||||
self.bundle = bundle
|
||||
self.log = log
|
||||
} catch {
|
||||
try? cgManager.delete()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ManagedContainer {
|
||||
// removeCgroupWithRetry will remove a cgroup path handling EAGAIN and EBUSY errors and
|
||||
// retrying the remove after an exponential timeout
|
||||
private func removeCgroupWithRetry() async throws {
|
||||
var delay = 10 // 10ms
|
||||
let maxRetries = 5
|
||||
|
||||
for i in 0..<maxRetries {
|
||||
if i != 0 {
|
||||
try await Task.sleep(for: .milliseconds(delay))
|
||||
delay *= 2
|
||||
}
|
||||
|
||||
do {
|
||||
try self.cgroupManager.delete(force: true)
|
||||
return
|
||||
} catch let error as Cgroup2Manager.Error {
|
||||
guard case .errno(let errnoValue, let message) = error,
|
||||
errnoValue == EBUSY || errnoValue == EAGAIN
|
||||
else {
|
||||
throw error
|
||||
}
|
||||
self.log.warning(
|
||||
"cgroup deletion failed with EBUSY/EAGAIN, retrying",
|
||||
metadata: [
|
||||
"attempt": "\(i + 1)",
|
||||
"delay": "\(delay)",
|
||||
"errno": "\(errnoValue)",
|
||||
"context": "\(message)",
|
||||
])
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "cgroups: unable to remove cgroup after \(maxRetries) retries"
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureExecExists(_ id: String) throws {
|
||||
if self.execs[id] == nil {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "exec \(id) does not exist in container \(self.id)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func createExec(
|
||||
id: String,
|
||||
stdio: HostStdio,
|
||||
process: ContainerizationOCI.Process
|
||||
) throws {
|
||||
log.debug("creating exec process with \(process)")
|
||||
|
||||
// Write the process config to the bundle, and pass this on
|
||||
// over to ManagedProcess to deal with.
|
||||
try self.bundle.createExecSpec(
|
||||
id: id,
|
||||
process: process
|
||||
)
|
||||
let process = try ManagedProcess(
|
||||
id: id,
|
||||
stdio: stdio,
|
||||
bundle: self.bundle,
|
||||
owningPid: self.initProcess.pid,
|
||||
log: self.log
|
||||
)
|
||||
self.execs[id] = process
|
||||
}
|
||||
|
||||
func start(execID: String) async throws -> Int32 {
|
||||
let proc = try self.getExecOrInit(execID: execID)
|
||||
return try await ProcessSupervisor.default.start(process: proc)
|
||||
}
|
||||
|
||||
func wait(execID: String) async throws -> ContainerExitStatus {
|
||||
let proc = try self.getExecOrInit(execID: execID)
|
||||
return await proc.wait()
|
||||
}
|
||||
|
||||
func kill(execID: String, _ signal: Int32) async throws {
|
||||
let proc = try self.getExecOrInit(execID: execID)
|
||||
try await proc.kill(signal)
|
||||
}
|
||||
|
||||
func resize(execID: String, size: Terminal.Size) throws {
|
||||
let proc = try self.getExecOrInit(execID: execID)
|
||||
try proc.resize(size: size)
|
||||
}
|
||||
|
||||
func closeStdin(execID: String) throws {
|
||||
let proc = try self.getExecOrInit(execID: execID)
|
||||
try proc.closeStdin()
|
||||
}
|
||||
|
||||
func deleteExec(id: String) throws {
|
||||
try ensureExecExists(id)
|
||||
do {
|
||||
try self.bundle.deleteExecSpec(id: id)
|
||||
} catch {
|
||||
self.log.error("failed to remove exec spec from filesystem: \(error)")
|
||||
}
|
||||
self.execs.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
func delete() async throws {
|
||||
// Delete the init process if it's a RuncProcess
|
||||
try await self.initProcess.delete()
|
||||
|
||||
// Delete the bundle and cgroup
|
||||
try self.bundle.delete()
|
||||
if self.needsCgroupCleanup {
|
||||
try await self.removeCgroupWithRetry()
|
||||
}
|
||||
}
|
||||
|
||||
func stats(_ categories: Cgroup2StatsCategory = .all) throws -> Cgroup2Stats {
|
||||
try self.cgroupManager.stats(categories)
|
||||
}
|
||||
|
||||
func getMemoryEvents() throws -> MemoryEvents {
|
||||
try self.cgroupManager.getMemoryEvents()
|
||||
}
|
||||
|
||||
func getExecOrInit(execID: String) throws -> any ContainerProcess {
|
||||
if execID == self.id {
|
||||
return self.initProcess
|
||||
}
|
||||
guard let proc = self.execs[execID] else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "exec \(execID) does not exist in container \(self.id)"
|
||||
)
|
||||
}
|
||||
return proc
|
||||
}
|
||||
}
|
||||
|
||||
extension ContainerizationOCI.Bundle {
|
||||
func createExecSpec(id: String, process: ContainerizationOCI.Process) throws {
|
||||
let specDir = self.path.appending(path: "execs/\(id)")
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(
|
||||
atPath: specDir.path,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let specData = try JSONEncoder().encode(process)
|
||||
let processConfigPath = specDir.appending(path: "process.json")
|
||||
try specData.write(to: processConfigPath)
|
||||
}
|
||||
|
||||
func getExecSpecPath(id: String) -> URL {
|
||||
self.path.appending(path: "execs/\(id)/process.json")
|
||||
}
|
||||
|
||||
func deleteExecSpec(id: String) throws {
|
||||
let specDir = self.path.appending(path: "execs/\(id)")
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.removeItem(at: specDir)
|
||||
}
|
||||
}
|
||||
|
||||
extension ManagedContainer {
|
||||
static func craftBundlePath(id: String) -> URL {
|
||||
URL(fileURLWithPath: "/run/container").appending(path: id)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,348 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Cgroup
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
final class ManagedProcess: ContainerProcess, Sendable {
|
||||
// swiftlint: disable type_name
|
||||
protocol IO {
|
||||
func attach(pid: Int32, fd: Int32) throws
|
||||
func start(process: inout Command) throws
|
||||
func resize(size: Terminal.Size) throws
|
||||
func close() throws
|
||||
func closeStdin() throws
|
||||
func closeAfterExec() throws
|
||||
}
|
||||
// swiftlint: enable type_name
|
||||
|
||||
private struct State {
|
||||
init(io: IO) {
|
||||
self.io = io
|
||||
}
|
||||
|
||||
let io: IO
|
||||
var waiters: [CheckedContinuation<ContainerExitStatus, Never>] = []
|
||||
var exitStatus: ContainerExitStatus? = nil
|
||||
var pid: Int32?
|
||||
}
|
||||
|
||||
private static let ackPid = "AckPid"
|
||||
private static let ackConsole = "AckConsole"
|
||||
|
||||
let id: String
|
||||
|
||||
private let log: Logger
|
||||
private let command: Command
|
||||
private let state: Mutex<State>
|
||||
private let owningPid: Int32?
|
||||
private let ackPipe: Pipe
|
||||
private let syncPipe: Pipe
|
||||
private let errorPipe: Pipe
|
||||
private let terminal: Bool
|
||||
private let bundle: ContainerizationOCI.Bundle
|
||||
|
||||
var pid: Int32? {
|
||||
self.state.withLock {
|
||||
$0.pid
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
stdio: HostStdio,
|
||||
bundle: ContainerizationOCI.Bundle,
|
||||
owningPid: Int32? = nil,
|
||||
log: Logger
|
||||
) throws {
|
||||
self.id = id
|
||||
var log = log
|
||||
log[metadataKey: "id"] = "\(id)"
|
||||
self.log = log
|
||||
self.owningPid = owningPid
|
||||
|
||||
let syncPipe = Pipe()
|
||||
try syncPipe.setCloexec()
|
||||
self.syncPipe = syncPipe
|
||||
|
||||
let ackPipe = Pipe()
|
||||
try ackPipe.setCloexec()
|
||||
self.ackPipe = ackPipe
|
||||
|
||||
let errorPipe = Pipe()
|
||||
try errorPipe.setCloexec()
|
||||
self.errorPipe = errorPipe
|
||||
|
||||
let args: [String]
|
||||
if let owningPid {
|
||||
args = [
|
||||
"exec",
|
||||
"--parent-pid",
|
||||
"\(owningPid)",
|
||||
"--process-path",
|
||||
bundle.getExecSpecPath(id: id).path,
|
||||
]
|
||||
} else {
|
||||
args = ["run", "--bundle-path", bundle.path.path]
|
||||
}
|
||||
|
||||
var command = Command(
|
||||
"/sbin/vmexec",
|
||||
arguments: args,
|
||||
extraFiles: [
|
||||
syncPipe.fileHandleForWriting,
|
||||
ackPipe.fileHandleForReading,
|
||||
errorPipe.fileHandleForWriting,
|
||||
]
|
||||
)
|
||||
|
||||
var io: IO
|
||||
if stdio.terminal {
|
||||
log.info("setting up terminal I/O")
|
||||
let attrs = Command.Attrs(setsid: false, setctty: false)
|
||||
command.attrs = attrs
|
||||
io = try TerminalIO(
|
||||
stdio: stdio,
|
||||
log: log
|
||||
)
|
||||
} else {
|
||||
command.attrs = .init(setsid: false)
|
||||
io = StandardIO(
|
||||
stdio: stdio,
|
||||
log: log
|
||||
)
|
||||
}
|
||||
|
||||
log.info("starting I/O")
|
||||
|
||||
// Setup IO early. We expect the host to be listening already.
|
||||
try io.start(process: &command)
|
||||
|
||||
self.command = command
|
||||
self.terminal = stdio.terminal
|
||||
self.bundle = bundle
|
||||
self.state = Mutex(State(io: io))
|
||||
}
|
||||
}
|
||||
|
||||
extension ManagedProcess {
|
||||
func start() async throws -> Int32 {
|
||||
do {
|
||||
return try self.state.withLock {
|
||||
log.info(
|
||||
"starting managed process",
|
||||
metadata: [
|
||||
"id": "\(id)"
|
||||
])
|
||||
|
||||
// Start the underlying process.
|
||||
try command.start()
|
||||
|
||||
defer {
|
||||
try? self.ackPipe.fileHandleForWriting.close()
|
||||
try? self.syncPipe.fileHandleForReading.close()
|
||||
try? self.ackPipe.fileHandleForReading.close()
|
||||
try? self.syncPipe.fileHandleForWriting.close()
|
||||
try? self.errorPipe.fileHandleForWriting.close()
|
||||
}
|
||||
|
||||
// Close our side of any pipes.
|
||||
try $0.io.closeAfterExec()
|
||||
try self.ackPipe.fileHandleForReading.close()
|
||||
try self.syncPipe.fileHandleForWriting.close()
|
||||
try self.errorPipe.fileHandleForWriting.close()
|
||||
|
||||
let size = MemoryLayout<Int32>.size
|
||||
guard let piddata = try syncPipe.fileHandleForReading.read(upToCount: size) else {
|
||||
throw ContainerizationError(.internalError, message: "no PID data from sync pipe")
|
||||
}
|
||||
|
||||
guard piddata.count == size else {
|
||||
throw ContainerizationError(.internalError, message: "invalid payload")
|
||||
}
|
||||
|
||||
let pid = piddata.withUnsafeBytes { ptr in
|
||||
ptr.load(as: Int32.self)
|
||||
}
|
||||
|
||||
log.info(
|
||||
"got back pid data",
|
||||
metadata: [
|
||||
"pid": "\(pid)"
|
||||
])
|
||||
$0.pid = pid
|
||||
|
||||
// This should probably happen in vmexec, but we don't need to set any cgroup
|
||||
// toggles so the problem is much simpler to just do it here.
|
||||
if let owningPid {
|
||||
let cgManager = try Cgroup2Manager.loadFromPid(pid: owningPid)
|
||||
try cgManager.addProcess(pid: pid)
|
||||
}
|
||||
|
||||
log.info(
|
||||
"sending pid acknowledgement",
|
||||
metadata: [
|
||||
"pid": "\(pid)"
|
||||
])
|
||||
try self.ackPipe.fileHandleForWriting.write(contentsOf: Self.ackPid.data(using: .utf8)!)
|
||||
|
||||
if self.terminal {
|
||||
log.info(
|
||||
"wait for PTY FD",
|
||||
metadata: [
|
||||
"id": "\(id)"
|
||||
])
|
||||
|
||||
// Wait for a new write that will contain the pty fd if we asked for one.
|
||||
guard let ptyFd = try self.syncPipe.fileHandleForReading.read(upToCount: size) else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "no PTY data from sync pipe"
|
||||
)
|
||||
}
|
||||
let fd = ptyFd.withUnsafeBytes { ptr in
|
||||
ptr.load(as: Int32.self)
|
||||
}
|
||||
log.info(
|
||||
"received PTY FD from container, attaching",
|
||||
metadata: [
|
||||
"id": "\(id)"
|
||||
])
|
||||
|
||||
try $0.io.attach(pid: pid, fd: fd)
|
||||
try self.ackPipe.fileHandleForWriting.write(contentsOf: Self.ackConsole.data(using: .utf8)!)
|
||||
}
|
||||
|
||||
// Wait for the errorPipe to close (after exec).
|
||||
if let errorData = try? self.errorPipe.fileHandleForReading.readToEnd(),
|
||||
let errorString = String(data: errorData, encoding: .utf8),
|
||||
!errorString.isEmpty
|
||||
{
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))"
|
||||
)
|
||||
}
|
||||
|
||||
log.info(
|
||||
"started managed process",
|
||||
metadata: [
|
||||
"pid": "\(pid)",
|
||||
"id": "\(id)",
|
||||
])
|
||||
|
||||
return pid
|
||||
}
|
||||
} catch {
|
||||
if let errorData = try? self.errorPipe.fileHandleForReading.readToEnd(),
|
||||
let errorString = String(data: errorData, encoding: .utf8),
|
||||
!errorString.isEmpty
|
||||
{
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "vmexec error: \(errorString.trimmingCharacters(in: .whitespacesAndNewlines))",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func setExit(_ status: Int32) {
|
||||
self.state.withLock { state in
|
||||
self.log.info(
|
||||
"managed process exit",
|
||||
metadata: [
|
||||
"status": "\(status)"
|
||||
])
|
||||
|
||||
let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now)
|
||||
state.exitStatus = exitStatus
|
||||
|
||||
do {
|
||||
try state.io.close()
|
||||
} catch {
|
||||
self.log.error("failed to close I/O for process: \(error)")
|
||||
}
|
||||
|
||||
for waiter in state.waiters {
|
||||
waiter.resume(returning: exitStatus)
|
||||
}
|
||||
|
||||
self.log.debug("\(state.waiters.count) managed process waiters signaled")
|
||||
state.waiters.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait on the process to exit
|
||||
func wait() async -> ContainerExitStatus {
|
||||
await withCheckedContinuation { cont in
|
||||
self.state.withLock {
|
||||
if let status = $0.exitStatus {
|
||||
cont.resume(returning: status)
|
||||
return
|
||||
}
|
||||
$0.waiters.append(cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func kill(_ signal: Int32) async throws {
|
||||
try self.state.withLock {
|
||||
guard let pid = $0.pid else {
|
||||
throw ContainerizationError(.invalidState, message: "process PID is required")
|
||||
}
|
||||
|
||||
guard $0.exitStatus == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.log.info("sending signal \(signal) to process \(pid)")
|
||||
guard Foundation.kill(pid, signal) == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
try self.state.withLock {
|
||||
guard $0.exitStatus == nil else {
|
||||
return
|
||||
}
|
||||
try $0.io.resize(size: size)
|
||||
}
|
||||
}
|
||||
|
||||
func closeStdin() throws {
|
||||
let io = self.state.withLock { $0.io }
|
||||
try io.closeStdin()
|
||||
}
|
||||
|
||||
func delete() async throws {
|
||||
// vmexec doesn't require explicit cleanup - the process is cleaned up
|
||||
// when it exits and IO is closed via setExit()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,158 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Cgroup
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
package final class MemoryMonitor: Sendable {
|
||||
private static let inotifyEventSize = 0x10
|
||||
|
||||
private let cgroupManager: Cgroup2Manager
|
||||
private let threshold: UInt64
|
||||
private let logger: Logger
|
||||
private let inotifyFd: Int32
|
||||
private let watchDescriptor: Int32
|
||||
private let onThresholdExceeded: @Sendable (UInt64, UInt64) -> Void
|
||||
|
||||
package init(
|
||||
cgroupManager: Cgroup2Manager,
|
||||
threshold: UInt64,
|
||||
logger: Logger,
|
||||
onThresholdExceeded: @escaping @Sendable (UInt64, UInt64) -> Void
|
||||
) throws {
|
||||
self.cgroupManager = cgroupManager
|
||||
self.threshold = threshold
|
||||
self.logger = logger
|
||||
self.onThresholdExceeded = onThresholdExceeded
|
||||
|
||||
let fd = inotify_init()
|
||||
guard fd != -1 else {
|
||||
throw Error.inotifyInit(errno: errno)
|
||||
}
|
||||
self.inotifyFd = fd
|
||||
|
||||
let eventsPath = cgroupManager.getMemoryEventsPath()
|
||||
let wd = inotify_add_watch(
|
||||
inotifyFd,
|
||||
eventsPath,
|
||||
UInt32(IN_MODIFY)
|
||||
)
|
||||
guard wd != -1 else {
|
||||
close(fd)
|
||||
throw Error.inotifyAddWatch(errno: errno, path: eventsPath)
|
||||
}
|
||||
self.watchDescriptor = wd
|
||||
}
|
||||
|
||||
/// Run the monitoring loop. Call this from a dedicated thread.
|
||||
/// This function blocks until an error occurs.
|
||||
package func run() throws {
|
||||
let eventsPath = cgroupManager.getMemoryEventsPath()
|
||||
|
||||
logger.info(
|
||||
"Started memory monitoring",
|
||||
metadata: [
|
||||
"threshold_bytes": "\(threshold)",
|
||||
"events_path": "\(eventsPath)",
|
||||
])
|
||||
|
||||
// Read initial state
|
||||
var highCountMax: UInt64 = 0
|
||||
do {
|
||||
let events = try cgroupManager.getMemoryEvents()
|
||||
highCountMax = events.high
|
||||
} catch {
|
||||
throw Error.readMemoryEvents(error: error)
|
||||
}
|
||||
|
||||
let bufSize = Self.inotifyEventSize * 10
|
||||
var buffer = [UInt8](repeating: 0, count: bufSize)
|
||||
while true {
|
||||
let bytesRead = buffer.withUnsafeMutableBytes { ptr in
|
||||
read(inotifyFd, ptr.baseAddress!, bufSize)
|
||||
}
|
||||
|
||||
if bytesRead < 0 {
|
||||
if errno == EINTR {
|
||||
continue
|
||||
}
|
||||
throw Error.readFailed(errno: errno)
|
||||
}
|
||||
|
||||
do {
|
||||
let events = try cgroupManager.getMemoryEvents()
|
||||
|
||||
if events.high > highCountMax {
|
||||
highCountMax = events.high
|
||||
|
||||
let stats = try cgroupManager.stats(.memory)
|
||||
let currentUsage = stats.memory?.usage ?? 0
|
||||
|
||||
onThresholdExceeded(currentUsage, events.high)
|
||||
}
|
||||
|
||||
if events.oom > 0 || events.oomKill > 0 {
|
||||
logger.error(
|
||||
"OOM events detected",
|
||||
metadata: [
|
||||
"oom_events": "\(events.oom)",
|
||||
"oom_kill_events": "\(events.oomKill)",
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
throw Error.readMemoryEvents(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
inotify_rm_watch(inotifyFd, watchDescriptor)
|
||||
close(inotifyFd)
|
||||
}
|
||||
}
|
||||
|
||||
extension MemoryMonitor {
|
||||
package enum Error: Swift.Error, CustomStringConvertible {
|
||||
case inotifyInit(errno: Int32)
|
||||
case inotifyAddWatch(errno: Int32, path: String)
|
||||
case readFailed(errno: Int32)
|
||||
case readMemoryEvents(error: Swift.Error)
|
||||
|
||||
package var description: String {
|
||||
switch self {
|
||||
case .inotifyInit(let errno):
|
||||
return "failed to initialize inotify: errno \(errno)"
|
||||
case .inotifyAddWatch(let errno, let path):
|
||||
return "failed to add inotify watch on \(path): errno \(errno)"
|
||||
case .readFailed(let errno):
|
||||
return "failed to read inotify events: errno \(errno)"
|
||||
case .readMemoryEvents(let error):
|
||||
return "failed to read memory events: \(error)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,106 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import LCShim
|
||||
|
||||
extension OSFile {
|
||||
struct SpliceFile: Sendable {
|
||||
fileprivate var file: OSFile
|
||||
fileprivate var offset: Int
|
||||
fileprivate let pipe = Pipe()
|
||||
|
||||
var fileDescriptor: Int32 {
|
||||
file.fileDescriptor
|
||||
}
|
||||
|
||||
var reader: Int32 {
|
||||
pipe.fileHandleForReading.fileDescriptor
|
||||
}
|
||||
|
||||
var writer: Int32 {
|
||||
pipe.fileHandleForWriting.fileDescriptor
|
||||
}
|
||||
|
||||
init(fd: Int32) {
|
||||
self.file = OSFile(fd: fd)
|
||||
self.offset = 0
|
||||
}
|
||||
|
||||
init(handle: FileHandle) {
|
||||
self.file = OSFile(handle: handle)
|
||||
self.offset = 0
|
||||
}
|
||||
|
||||
init(from: OSFile, withOffset: Int = 0) {
|
||||
self.file = from
|
||||
self.offset = withOffset
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
try self.file.close()
|
||||
}
|
||||
}
|
||||
|
||||
static func splice(from: inout SpliceFile, to: inout SpliceFile, count: Int = 1 << 16) throws -> (read: Int, wrote: Int, action: IOAction) {
|
||||
let fromOffset = from.offset
|
||||
let toOffset = to.offset
|
||||
|
||||
while true {
|
||||
while (from.offset - to.offset) < count {
|
||||
let toRead = count - (from.offset - to.offset)
|
||||
let bytesRead = LCShim.splice(from.fileDescriptor, nil, to.writer, nil, toRead, UInt32(bitPattern: LCShim.SPLICE_F_MOVE | LCShim.SPLICE_F_NONBLOCK))
|
||||
if bytesRead == -1 {
|
||||
if errno != EAGAIN && errno != EIO {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
break
|
||||
}
|
||||
if bytesRead == 0 {
|
||||
return (0, 0, .eof)
|
||||
}
|
||||
from.offset += bytesRead
|
||||
if bytesRead < toRead {
|
||||
break
|
||||
}
|
||||
}
|
||||
if from.offset == to.offset {
|
||||
return (from.offset - fromOffset, to.offset - toOffset, .success)
|
||||
}
|
||||
while to.offset < from.offset {
|
||||
let toWrite = from.offset - to.offset
|
||||
let bytesWrote = LCShim.splice(to.reader, nil, to.fileDescriptor, nil, toWrite, UInt32(bitPattern: LCShim.SPLICE_F_MOVE | LCShim.SPLICE_F_NONBLOCK))
|
||||
if bytesWrote == -1 {
|
||||
if errno != EAGAIN && errno != EIO {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
break
|
||||
}
|
||||
to.offset += bytesWrote
|
||||
if bytesWrote == 0 {
|
||||
return (from.offset - fromOffset, to.offset - toOffset, .brokenPipe)
|
||||
}
|
||||
if bytesWrote < toWrite {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,132 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
struct OSFile: Sendable {
|
||||
enum IOAction: Equatable {
|
||||
case eof
|
||||
case again
|
||||
case success
|
||||
case brokenPipe
|
||||
case error(_ errno: Int32)
|
||||
}
|
||||
|
||||
private let fd: Int32
|
||||
|
||||
var closed: Bool {
|
||||
Foundation.fcntl(fd, F_GETFD) == -1 && errno == EBADF
|
||||
}
|
||||
|
||||
var fileDescriptor: Int32 { fd }
|
||||
|
||||
init(fd: Int32) {
|
||||
self.fd = fd
|
||||
}
|
||||
|
||||
init(handle: FileHandle) {
|
||||
self.fd = handle.fileDescriptor
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
guard Foundation.close(self.fd) == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
}
|
||||
|
||||
func read(_ buffer: UnsafeMutableBufferPointer<UInt8>) -> (read: Int, action: IOAction) {
|
||||
if buffer.count == 0 {
|
||||
return (0, .success)
|
||||
}
|
||||
|
||||
var bytesRead: Int = 0
|
||||
while true {
|
||||
let n = Foundation.read(
|
||||
self.fd,
|
||||
buffer.baseAddress!.advanced(by: bytesRead),
|
||||
buffer.count - bytesRead
|
||||
)
|
||||
if n == -1 {
|
||||
if errno == EAGAIN || errno == EIO {
|
||||
return (bytesRead, .again)
|
||||
}
|
||||
return (bytesRead, .error(errno))
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return (bytesRead, .eof)
|
||||
}
|
||||
|
||||
bytesRead += n
|
||||
if bytesRead < buffer.count {
|
||||
continue
|
||||
}
|
||||
return (bytesRead, .success)
|
||||
}
|
||||
}
|
||||
|
||||
func write(_ buffer: UnsafeMutableBufferPointer<UInt8>) -> (wrote: Int, action: IOAction) {
|
||||
if buffer.count == 0 {
|
||||
return (0, .success)
|
||||
}
|
||||
|
||||
var bytesWrote: Int = 0
|
||||
while true {
|
||||
let n = Foundation.write(
|
||||
self.fd,
|
||||
buffer.baseAddress!.advanced(by: bytesWrote),
|
||||
buffer.count - bytesWrote
|
||||
)
|
||||
if n == -1 {
|
||||
if errno == EAGAIN || errno == EIO {
|
||||
return (bytesWrote, .again)
|
||||
}
|
||||
return (bytesWrote, .error(errno))
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return (bytesWrote, .brokenPipe)
|
||||
}
|
||||
|
||||
bytesWrote += n
|
||||
if bytesWrote < buffer.count {
|
||||
continue
|
||||
}
|
||||
return (bytesWrote, .success)
|
||||
}
|
||||
}
|
||||
|
||||
static func pipe() -> (read: Self, write: Self) {
|
||||
let pipe = Pipe()
|
||||
return (Self(handle: pipe.fileHandleForReading), Self(handle: pipe.fileHandleForWriting))
|
||||
}
|
||||
|
||||
static func open(path: String) throws -> Self {
|
||||
try open(path: path, mode: O_RDONLY | O_CLOEXEC)
|
||||
}
|
||||
|
||||
static func open(path: String, mode: Int32) throws -> Self {
|
||||
let fd = Foundation.open(path, mode)
|
||||
if fd < 0 {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
return Self(fd: fd)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,82 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ArgumentParser
|
||||
import Dispatch
|
||||
import Logging
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _exit = Musl.exit
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _exit = Glibc.exit
|
||||
#endif
|
||||
|
||||
public struct PauseCommand: ParsableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "pause",
|
||||
abstract: "Run the pause container"
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@OptionGroup var options: LogLevelOption
|
||||
|
||||
public mutating func run() throws {
|
||||
let log = makeLogger(label: "pause", level: options.resolvedLogLevel())
|
||||
|
||||
if getpid() != 1 {
|
||||
log.warning("pause should be the first process")
|
||||
}
|
||||
|
||||
// NOTE: For whatever reason, using signal() for the below causes a swift compiler issue.
|
||||
// Can revert whenever that is understood.
|
||||
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT)
|
||||
sigintSource.setEventHandler {
|
||||
log.info("Shutting down, got SIGINT")
|
||||
_exit(0)
|
||||
}
|
||||
sigintSource.resume()
|
||||
|
||||
let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM)
|
||||
sigtermSource.setEventHandler {
|
||||
log.info("Shutting down, got SIGTERM")
|
||||
_exit(0)
|
||||
}
|
||||
sigtermSource.resume()
|
||||
|
||||
let sigchldSource = DispatchSource.makeSignalSource(signal: SIGCHLD)
|
||||
sigchldSource.setEventHandler {
|
||||
var status: Int32 = 0
|
||||
while waitpid(-1, &status, WNOHANG) > 0 {}
|
||||
}
|
||||
sigchldSource.resume()
|
||||
|
||||
log.info("pause container running, waiting for signals...")
|
||||
|
||||
while true {
|
||||
_ = pause()
|
||||
}
|
||||
|
||||
log.error("Error: infinite loop terminated")
|
||||
_exit(42)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,168 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
final class ProcessSupervisor: Sendable {
|
||||
private let poller: Epoll
|
||||
private let handlers = Mutex<[Int32: @Sendable (Epoll.Mask) -> Void]>([:])
|
||||
|
||||
private let queue: DispatchQueue
|
||||
// `DispatchSourceSignal` is thread-safe.
|
||||
private nonisolated(unsafe) let source: DispatchSourceSignal
|
||||
|
||||
private struct State {
|
||||
var processes: [any ContainerProcess] = []
|
||||
var log: Logger?
|
||||
}
|
||||
|
||||
private let state: Mutex<State>
|
||||
private let reaperCommandRunner = ReaperCommandRunner()
|
||||
|
||||
func setLog(_ log: Logger?) {
|
||||
self.state.withLock { $0.log = log }
|
||||
}
|
||||
|
||||
static let `default` = ProcessSupervisor()
|
||||
|
||||
private init() {
|
||||
let queue = DispatchQueue(label: "process-supervisor")
|
||||
self.source = DispatchSource.makeSignalSource(signal: SIGCHLD, queue: queue)
|
||||
self.queue = queue
|
||||
self.poller = try! Epoll()
|
||||
self.state = Mutex(State())
|
||||
let t = Thread {
|
||||
while true {
|
||||
guard let events = self.poller.wait() else {
|
||||
return
|
||||
}
|
||||
if events.isEmpty {
|
||||
return
|
||||
}
|
||||
for event in events {
|
||||
let handler = self.handlers.withLock { $0[event.fd] }
|
||||
handler?(event.mask)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.start()
|
||||
}
|
||||
|
||||
/// Register a file descriptor for epoll monitoring with a handler.
|
||||
///
|
||||
/// The handler is stored before the fd is added to epoll, ensuring no
|
||||
/// events are missed.
|
||||
func registerFd(
|
||||
_ fd: Int32,
|
||||
mask: Epoll.Mask = [.input, .output],
|
||||
handler: @escaping @Sendable (Epoll.Mask) -> Void
|
||||
) throws {
|
||||
self.handlers.withLock { $0[fd] = handler }
|
||||
do {
|
||||
try self.poller.add(fd, mask: mask)
|
||||
} catch {
|
||||
self.handlers.withLock { _ = $0.removeValue(forKey: fd) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a file descriptor from epoll monitoring and discard its handler.
|
||||
func unregisterFd(_ fd: Int32) throws {
|
||||
self.handlers.withLock { _ = $0.removeValue(forKey: fd) }
|
||||
try self.poller.delete(fd)
|
||||
}
|
||||
|
||||
func ready() {
|
||||
self.source.setEventHandler {
|
||||
self.handleSignal()
|
||||
}
|
||||
self.source.resume()
|
||||
}
|
||||
|
||||
private func handleSignal() {
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
|
||||
let exited = Reaper.reap()
|
||||
|
||||
for (pid, status) in exited {
|
||||
reaperCommandRunner.notifyExit(pid: pid, status: status)
|
||||
}
|
||||
|
||||
self.state.withLock { state in
|
||||
state.log?.debug("received SIGCHLD, reaping processes")
|
||||
state.log?.debug("finished wait4 of \(exited.count) processes")
|
||||
state.log?.debug("checking for exit of managed process", metadata: ["exits": "\(exited)", "processes": "\(state.processes.count)"])
|
||||
|
||||
let exitedProcesses = state.processes.filter { proc in
|
||||
exited.contains { pid, _ in
|
||||
proc.pid == pid
|
||||
}
|
||||
}
|
||||
|
||||
for proc in exitedProcesses {
|
||||
guard let pid = proc.pid else {
|
||||
continue
|
||||
}
|
||||
|
||||
if let status = exited[pid] {
|
||||
state.log?.debug(
|
||||
"managed process exited",
|
||||
metadata: [
|
||||
"pid": "\(pid)",
|
||||
"status": "\(status)",
|
||||
"count": "\(state.processes.count - 1)",
|
||||
])
|
||||
proc.setExit(status)
|
||||
state.processes.removeAll(where: { $0.pid == pid })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start(process: any ContainerProcess) async throws -> Int32 {
|
||||
self.state.withLock { state in
|
||||
state.log?.debug("in supervisor lock to start process")
|
||||
state.processes.append(process)
|
||||
}
|
||||
do {
|
||||
return try await process.start()
|
||||
} catch {
|
||||
self.state.withLock { state in
|
||||
state.processes.removeAll(where: { $0.id == process.id })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
source.cancel()
|
||||
poller.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,83 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
/// A Unix socket for receiving PTY master file descriptors from runc
|
||||
final class ConsoleSocket: Sendable {
|
||||
private let socket: Socket
|
||||
private let socketPath: String
|
||||
|
||||
/// The path to the console socket
|
||||
var path: String { socketPath }
|
||||
|
||||
/// Create a new console socket at the specified path
|
||||
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)
|
||||
|
||||
try socket.listen()
|
||||
}
|
||||
|
||||
/// Create a temporary console socket in the runtime directory
|
||||
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
|
||||
func receiveMaster() throws -> Int32 {
|
||||
let connection = try socket.accept()
|
||||
defer { try? connection.close() }
|
||||
return try connection.receiveFileDescriptor()
|
||||
}
|
||||
|
||||
/// Close the socket and optionally remove the socket file
|
||||
func close() throws {
|
||||
try socket.close()
|
||||
try FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? close()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,794 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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<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,
|
||||
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
|
||||
@@ -0,0 +1,564 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(Linux)
|
||||
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
/// A container process implementation that uses runc as the OCI runtime
|
||||
final class RuncProcess: ContainerProcess, Sendable {
|
||||
// swiftlint: disable type_name
|
||||
protocol IO: Sendable {
|
||||
func attachConsole(fd: Int32) throws
|
||||
func create() throws
|
||||
func getIO() -> Runc.IO
|
||||
func closeAfterExec() throws
|
||||
func resize(size: Terminal.Size) throws
|
||||
func close() throws
|
||||
func closeStdin() throws
|
||||
}
|
||||
// swiftlint: enable type_name
|
||||
|
||||
private enum ProcessState {
|
||||
case initial
|
||||
case creating
|
||||
case running(pid: Int32)
|
||||
case exited(ContainerExitStatus)
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var state: ProcessState = .initial
|
||||
var waiters: [CheckedContinuation<ContainerExitStatus, Never>] = []
|
||||
}
|
||||
|
||||
let id: String
|
||||
|
||||
private let log: Logger
|
||||
private let runc: Runc
|
||||
private let io: IO
|
||||
private let state: Mutex<State>
|
||||
private let terminal: Bool
|
||||
private let bundle: ContainerizationOCI.Bundle
|
||||
private let consoleSocket: ConsoleSocket?
|
||||
|
||||
var pid: Int32? {
|
||||
self.state.withLock {
|
||||
switch $0.state {
|
||||
case .running(let pid):
|
||||
return pid
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
id: String,
|
||||
stdio: HostStdio,
|
||||
bundle: ContainerizationOCI.Bundle,
|
||||
runc: Runc,
|
||||
log: Logger
|
||||
) throws {
|
||||
self.id = id
|
||||
var log = log
|
||||
log[metadataKey: "id"] = "\(id)"
|
||||
self.log = log
|
||||
self.runc = runc
|
||||
self.bundle = bundle
|
||||
self.terminal = stdio.terminal
|
||||
|
||||
var io: IO
|
||||
var consoleSocket: ConsoleSocket? = nil
|
||||
|
||||
if stdio.terminal {
|
||||
log.info("setting up terminal I/O for runc")
|
||||
let socket = try ConsoleSocket.temporary()
|
||||
consoleSocket = socket
|
||||
io = try RuncTerminalIO(
|
||||
stdio: stdio,
|
||||
log: log
|
||||
)
|
||||
} else {
|
||||
io = RuncStandardIO(
|
||||
stdio: stdio,
|
||||
log: log
|
||||
)
|
||||
}
|
||||
|
||||
log.info("starting I/O for runc")
|
||||
try io.create()
|
||||
|
||||
self.consoleSocket = consoleSocket
|
||||
self.io = io
|
||||
self.state = Mutex(State())
|
||||
}
|
||||
|
||||
func start() async throws -> Int32 {
|
||||
try self.state.withLock {
|
||||
guard case .initial = $0.state else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container already started"
|
||||
)
|
||||
}
|
||||
$0.state = .creating
|
||||
}
|
||||
|
||||
log.info(
|
||||
"starting runc process",
|
||||
metadata: [
|
||||
"id": "\(id)"
|
||||
])
|
||||
|
||||
let pidFilePath = self.bundle.path.appendingPathComponent("runc-pid").path
|
||||
let runcIO = self.io.getIO()
|
||||
|
||||
let opts: CreateOpts
|
||||
if let consoleSocket {
|
||||
opts = CreateOpts(
|
||||
pidFile: pidFilePath,
|
||||
consoleSocket: consoleSocket.path,
|
||||
io: runcIO
|
||||
)
|
||||
} else {
|
||||
opts = CreateOpts(
|
||||
pidFile: pidFilePath,
|
||||
io: runcIO
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let pidInt = try await self.runc.create(
|
||||
id: self.id,
|
||||
bundle: self.bundle.path.path,
|
||||
opts: opts
|
||||
)
|
||||
else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "runc create did not return a PID"
|
||||
)
|
||||
}
|
||||
|
||||
let pid = Int32(pidInt)
|
||||
|
||||
self.log.info(
|
||||
"container created",
|
||||
metadata: [
|
||||
"pid": "\(pid)"
|
||||
])
|
||||
|
||||
// Close the pipe ends we gave to runc now that it has inherited them
|
||||
// and attach console if in terminal mode
|
||||
if self.terminal, let consoleSocket = self.consoleSocket {
|
||||
self.log.info("waiting for console FD from runc")
|
||||
let ptyFd = try consoleSocket.receiveMaster()
|
||||
|
||||
self.log.info(
|
||||
"received PTY FD from runc, attaching",
|
||||
metadata: [
|
||||
"id": "\(self.id)"
|
||||
])
|
||||
|
||||
try self.io.closeAfterExec()
|
||||
try self.io.attachConsole(fd: ptyFd)
|
||||
} else {
|
||||
try self.io.closeAfterExec()
|
||||
}
|
||||
|
||||
try await self.runc.start(id: self.id)
|
||||
|
||||
self.state.withLock {
|
||||
$0.state = .running(pid: pid)
|
||||
}
|
||||
|
||||
self.log.info(
|
||||
"started runc process",
|
||||
metadata: [
|
||||
"pid": "\(pid)",
|
||||
"id": "\(self.id)",
|
||||
])
|
||||
|
||||
return pid
|
||||
}
|
||||
|
||||
func setExit(_ status: Int32) {
|
||||
self.state.withLock {
|
||||
self.log.info(
|
||||
"runc process exit",
|
||||
metadata: [
|
||||
"status": "\(status)"
|
||||
])
|
||||
|
||||
let exitStatus = ContainerExitStatus(exitCode: status, exitedAt: Date.now)
|
||||
$0.state = .exited(exitStatus)
|
||||
|
||||
do {
|
||||
try self.io.close()
|
||||
} catch {
|
||||
self.log.error("failed to close I/O for process: \(error)")
|
||||
}
|
||||
|
||||
for waiter in $0.waiters {
|
||||
waiter.resume(returning: exitStatus)
|
||||
}
|
||||
|
||||
self.log.debug("\($0.waiters.count) runc process waiters signaled")
|
||||
$0.waiters.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
func wait() async -> ContainerExitStatus {
|
||||
await withCheckedContinuation { cont in
|
||||
self.state.withLock {
|
||||
if case .exited(let exitStatus) = $0.state {
|
||||
cont.resume(returning: exitStatus)
|
||||
return
|
||||
}
|
||||
$0.waiters.append(cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func kill(_ signal: Int32) async throws {
|
||||
self.log.info("sending signal \(signal) to runc container \(id)")
|
||||
try await self.runc.kill(id: self.id, signal: signal)
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
try self.state.withLock {
|
||||
if case .exited = $0.state {
|
||||
return
|
||||
}
|
||||
try self.io.resize(size: size)
|
||||
}
|
||||
}
|
||||
|
||||
func closeStdin() throws {
|
||||
try self.io.closeStdin()
|
||||
}
|
||||
|
||||
func delete() async throws {
|
||||
let shouldDelete = self.state.withLock { state -> Bool in
|
||||
switch state.state {
|
||||
case .initial, .creating:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
guard shouldDelete else {
|
||||
log.info("container was never created, skipping delete")
|
||||
return
|
||||
}
|
||||
|
||||
log.info("deleting runc container", metadata: ["id": "\(id)"])
|
||||
|
||||
try await self.runc.delete(
|
||||
id: self.id,
|
||||
opts: DeleteOpts(force: true)
|
||||
)
|
||||
|
||||
if let consoleSocket = self.consoleSocket {
|
||||
try consoleSocket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RuncTerminalIO
|
||||
|
||||
final class RuncTerminalIO: RuncProcess.IO & Sendable {
|
||||
private struct State {
|
||||
var stdinSocket: Socket?
|
||||
var stdoutSocket: Socket?
|
||||
|
||||
var stdin: IOPair?
|
||||
var stdout: IOPair?
|
||||
var terminal: Terminal?
|
||||
}
|
||||
|
||||
private let log: Logger?
|
||||
private let hostStdio: HostStdio
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(
|
||||
stdio: HostStdio,
|
||||
log: Logger?
|
||||
) throws {
|
||||
self.hostStdio = stdio
|
||||
self.log = log
|
||||
self.state = Mutex(State())
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
try self.state.withLock {
|
||||
if let terminal = $0.terminal {
|
||||
try terminal.resize(size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func create() throws {
|
||||
try self.state.withLock {
|
||||
if let stdinPort = self.hostStdio.stdin {
|
||||
let type = VsockType(
|
||||
port: stdinPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdinSocket.connect()
|
||||
$0.stdinSocket = stdinSocket
|
||||
}
|
||||
|
||||
if let stdoutPort = self.hostStdio.stdout {
|
||||
let type = VsockType(
|
||||
port: stdoutPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdoutSocket.connect()
|
||||
$0.stdoutSocket = stdoutSocket
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIO() -> Runc.IO {
|
||||
// Terminal mode doesn't pass pipes to runc, it uses the console socket
|
||||
.inherit
|
||||
}
|
||||
|
||||
func closeAfterExec() throws {
|
||||
// No pipes to close in terminal mode
|
||||
}
|
||||
|
||||
func attachConsole(fd: Int32) throws {
|
||||
try self.state.withLock {
|
||||
let term = try Terminal(descriptor: fd, setInitState: false)
|
||||
$0.terminal = term
|
||||
|
||||
if let stdinSocket = $0.stdinSocket {
|
||||
let pair = IOPair(
|
||||
readFrom: stdinSocket,
|
||||
writeTo: term,
|
||||
reason: "RuncTerminalIO stdin",
|
||||
logger: log
|
||||
)
|
||||
try pair.relay(ignoreHup: true)
|
||||
$0.stdin = pair
|
||||
}
|
||||
|
||||
if let stdoutSocket = $0.stdoutSocket {
|
||||
let pair = IOPair(
|
||||
readFrom: term,
|
||||
writeTo: stdoutSocket,
|
||||
reason: "RuncTerminalIO stdout",
|
||||
logger: log
|
||||
)
|
||||
try pair.relay(ignoreHup: true)
|
||||
$0.stdout = pair
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
if let stdout = $0.stdout {
|
||||
stdout.close()
|
||||
$0.stdout = nil
|
||||
}
|
||||
$0.terminal = nil
|
||||
}
|
||||
}
|
||||
|
||||
func closeStdin() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RuncStandardIO
|
||||
|
||||
final class RuncStandardIO: RuncProcess.IO & Sendable {
|
||||
private struct State {
|
||||
var stdin: IOPair?
|
||||
var stdout: IOPair?
|
||||
var stderr: IOPair?
|
||||
|
||||
var stdinPipe: Pipe?
|
||||
var stdoutPipe: Pipe?
|
||||
var stderrPipe: Pipe?
|
||||
}
|
||||
|
||||
private let log: Logger?
|
||||
private let hostStdio: HostStdio
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(
|
||||
stdio: HostStdio,
|
||||
log: Logger?
|
||||
) {
|
||||
self.hostStdio = stdio
|
||||
self.log = log
|
||||
self.state = Mutex(State())
|
||||
}
|
||||
|
||||
// NOP for non-terminal
|
||||
func attachConsole(fd: Int32) throws {}
|
||||
|
||||
func create() throws {
|
||||
try self.state.withLock {
|
||||
if let stdinPort = self.hostStdio.stdin {
|
||||
let inPipe = Pipe()
|
||||
$0.stdinPipe = inPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stdinPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdinSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: stdinSocket,
|
||||
writeTo: inPipe.fileHandleForWriting,
|
||||
reason: "RuncStandardIO stdin",
|
||||
logger: log
|
||||
)
|
||||
$0.stdin = pair
|
||||
try pair.relay()
|
||||
}
|
||||
|
||||
if let stdoutPort = self.hostStdio.stdout {
|
||||
let outPipe = Pipe()
|
||||
$0.stdoutPipe = outPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stdoutPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdoutSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: outPipe.fileHandleForReading,
|
||||
writeTo: stdoutSocket,
|
||||
reason: "RuncStandardIO stdout",
|
||||
logger: log
|
||||
)
|
||||
$0.stdout = pair
|
||||
try pair.relay()
|
||||
}
|
||||
|
||||
if let stderrPort = self.hostStdio.stderr {
|
||||
let errPipe = Pipe()
|
||||
$0.stderrPipe = errPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stderrPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stderrSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stderrSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: errPipe.fileHandleForReading,
|
||||
writeTo: stderrSocket,
|
||||
reason: "RuncStandardIO stderr",
|
||||
logger: log
|
||||
)
|
||||
$0.stderr = pair
|
||||
try pair.relay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getIO() -> Runc.IO {
|
||||
self.state.withLock {
|
||||
Runc.IO(
|
||||
stdin: $0.stdinPipe?.fileHandleForReading,
|
||||
stdout: $0.stdoutPipe?.fileHandleForWriting,
|
||||
stderr: $0.stderrPipe?.fileHandleForWriting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func closeAfterExec() throws {
|
||||
try self.state.withLock {
|
||||
// Close the pipe ends we gave to runc (the child inherited them)
|
||||
if let stdinPipe = $0.stdinPipe {
|
||||
try stdinPipe.fileHandleForReading.close()
|
||||
$0.stdinPipe = nil
|
||||
}
|
||||
if let stdoutPipe = $0.stdoutPipe {
|
||||
try stdoutPipe.fileHandleForWriting.close()
|
||||
$0.stdoutPipe = nil
|
||||
}
|
||||
if let stderrPipe = $0.stderrPipe {
|
||||
try stderrPipe.fileHandleForWriting.close()
|
||||
$0.stderrPipe = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
throw ContainerizationError(.unsupported, message: "resize not supported for standard IO")
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
|
||||
if let stdout = $0.stdout {
|
||||
stdout.close()
|
||||
$0.stdout = nil
|
||||
}
|
||||
|
||||
if let stderr = $0.stderr {
|
||||
stderr.close()
|
||||
$0.stderr = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeStdin() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import GRPCNIOTransportHTTP2
|
||||
import GRPCProtobuf
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
public final class Initd: Sendable {
|
||||
public actor State {
|
||||
private(set) var containers: [String: ManagedContainer] = [:]
|
||||
var proxies: [String: VsockProxy] = [:]
|
||||
|
||||
public typealias ContainerDeletedHandler = @Sendable (String) async -> Void
|
||||
private var onContainerDeleted: [ContainerDeletedHandler] = []
|
||||
|
||||
public func onDelete(_ handler: @escaping ContainerDeletedHandler) {
|
||||
onContainerDeleted.append(handler)
|
||||
}
|
||||
|
||||
public func get(container id: String) throws -> ManagedContainer {
|
||||
guard let ctr = self.containers[id] else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "container \(id) not found"
|
||||
)
|
||||
}
|
||||
return ctr
|
||||
}
|
||||
|
||||
func add(container: ManagedContainer) throws {
|
||||
guard containers[container.id] == nil else {
|
||||
throw ContainerizationError(
|
||||
.exists,
|
||||
message: "container \(container.id) already exists"
|
||||
)
|
||||
}
|
||||
containers[container.id] = container
|
||||
}
|
||||
|
||||
func add(proxy: VsockProxy) throws {
|
||||
guard proxies[proxy.id] == nil else {
|
||||
throw ContainerizationError(
|
||||
.exists,
|
||||
message: "proxy \(proxy.id) already exists"
|
||||
)
|
||||
}
|
||||
proxies[proxy.id] = proxy
|
||||
}
|
||||
|
||||
func remove(proxy id: String) throws -> VsockProxy {
|
||||
guard let proxy = proxies.removeValue(forKey: id) else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "proxy \(id) does not exist"
|
||||
)
|
||||
}
|
||||
return proxy
|
||||
}
|
||||
|
||||
func remove(container id: String) throws {
|
||||
guard let _ = containers.removeValue(forKey: id) else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "container \(id) does not exist"
|
||||
)
|
||||
}
|
||||
let handlers = onContainerDeleted
|
||||
Task {
|
||||
for handler in handlers {
|
||||
await handler(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public let log: Logger
|
||||
public let state: State
|
||||
let group: MultiThreadedEventLoopGroup
|
||||
let blockingPool: NIOThreadPool
|
||||
|
||||
public init(log: Logger, group: MultiThreadedEventLoopGroup, blockingPool: NIOThreadPool) {
|
||||
self.log = log
|
||||
self.group = group
|
||||
self.blockingPool = blockingPool
|
||||
self.state = State()
|
||||
}
|
||||
|
||||
public func serve(port: Int, additionalServices: [any RegistrableRPCService] = []) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
log.debug("starting process supervisor")
|
||||
|
||||
ProcessSupervisor.default.setLog(self.log)
|
||||
ProcessSupervisor.default.ready()
|
||||
|
||||
log.info(
|
||||
"booting gRPC server on vsock",
|
||||
metadata: [
|
||||
"port": "\(port)"
|
||||
])
|
||||
|
||||
let server = GRPCServer(
|
||||
transport: .http2NIOPosix(
|
||||
address: .vsock(contextID: .any, port: .init(port)),
|
||||
transportSecurity: .plaintext,
|
||||
eventLoopGroup: self.group
|
||||
),
|
||||
services: [self] + additionalServices
|
||||
)
|
||||
|
||||
log.info(
|
||||
"gRPC API serving on vsock",
|
||||
metadata: [
|
||||
"port": "\(port)"
|
||||
])
|
||||
|
||||
group.addTask { try await server.serve() }
|
||||
|
||||
try await group.next()
|
||||
log.info("closing gRPC server")
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,175 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
final class StandardIO: ManagedProcess.IO & Sendable {
|
||||
private struct State {
|
||||
var stdin: IOPair?
|
||||
var stdout: IOPair?
|
||||
var stderr: IOPair?
|
||||
|
||||
var stdinPipe: Pipe?
|
||||
var stdoutPipe: Pipe?
|
||||
var stderrPipe: Pipe?
|
||||
}
|
||||
|
||||
private let log: Logger?
|
||||
private let hostStdio: HostStdio
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(
|
||||
stdio: HostStdio,
|
||||
log: Logger?
|
||||
) {
|
||||
self.hostStdio = stdio
|
||||
self.log = log
|
||||
self.state = Mutex(State())
|
||||
}
|
||||
|
||||
// NOP
|
||||
func attach(pid: Int32, fd: Int32) throws {}
|
||||
|
||||
func start(process: inout Command) throws {
|
||||
try self.state.withLock {
|
||||
if let stdinPort = self.hostStdio.stdin {
|
||||
let inPipe = Pipe()
|
||||
process.stdin = inPipe.fileHandleForReading
|
||||
$0.stdinPipe = inPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stdinPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdinSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: stdinSocket,
|
||||
writeTo: inPipe.fileHandleForWriting,
|
||||
reason: "StandardIO stdin",
|
||||
logger: log
|
||||
)
|
||||
$0.stdin = pair
|
||||
|
||||
try pair.relay()
|
||||
}
|
||||
|
||||
if let stdoutPort = self.hostStdio.stdout {
|
||||
let outPipe = Pipe()
|
||||
process.stdout = outPipe.fileHandleForWriting
|
||||
$0.stdoutPipe = outPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stdoutPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdoutSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: outPipe.fileHandleForReading,
|
||||
writeTo: stdoutSocket,
|
||||
reason: "StandardIO stdout",
|
||||
logger: log
|
||||
)
|
||||
$0.stdout = pair
|
||||
|
||||
try pair.relay()
|
||||
}
|
||||
|
||||
if let stderrPort = self.hostStdio.stderr {
|
||||
let errPipe = Pipe()
|
||||
process.stderr = errPipe.fileHandleForWriting
|
||||
$0.stderrPipe = errPipe
|
||||
|
||||
let type = VsockType(
|
||||
port: stderrPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stderrSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stderrSocket.connect()
|
||||
|
||||
let pair = IOPair(
|
||||
readFrom: errPipe.fileHandleForReading,
|
||||
writeTo: stderrSocket,
|
||||
reason: "StandardIO stderr",
|
||||
logger: log
|
||||
)
|
||||
$0.stderr = pair
|
||||
|
||||
try pair.relay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
throw ContainerizationError(.unsupported, message: "resize not supported")
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
|
||||
if let stdout = $0.stdout {
|
||||
stdout.close()
|
||||
$0.stdout = nil
|
||||
}
|
||||
|
||||
if let stderr = $0.stderr {
|
||||
stderr.close()
|
||||
$0.stderr = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeStdin() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func closeAfterExec() throws {
|
||||
try self.state.withLock {
|
||||
if let stdin = $0.stdinPipe {
|
||||
try stdin.fileHandleForReading.close()
|
||||
$0.stdinPipe = nil
|
||||
}
|
||||
if let stdout = $0.stdoutPipe {
|
||||
try stdout.fileHandleForWriting.close()
|
||||
$0.stdoutPipe = nil
|
||||
}
|
||||
if let stderr = $0.stderrPipe {
|
||||
try stderr.fileHandleForWriting.close()
|
||||
$0.stderrPipe = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,169 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
import LCShim
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
final class TerminalIO: ManagedProcess.IO & Sendable {
|
||||
private struct State {
|
||||
var stdinSocket: Socket?
|
||||
var stdoutSocket: Socket?
|
||||
|
||||
var stdin: IOPair?
|
||||
var stdout: IOPair?
|
||||
var parent: Terminal?
|
||||
}
|
||||
|
||||
private let log: Logger?
|
||||
private let hostStdio: HostStdio
|
||||
private let state: Mutex<State>
|
||||
|
||||
init(
|
||||
stdio: HostStdio,
|
||||
log: Logger?
|
||||
) throws {
|
||||
self.hostStdio = stdio
|
||||
self.log = log
|
||||
self.state = Mutex(State())
|
||||
}
|
||||
|
||||
func resize(size: Terminal.Size) throws {
|
||||
try self.state.withLock {
|
||||
if let parent = $0.parent {
|
||||
try parent.resize(size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start(process: inout Command) throws {
|
||||
try self.state.withLock {
|
||||
process.stdin = nil
|
||||
process.stdout = nil
|
||||
process.stderr = nil
|
||||
|
||||
if let stdinPort = self.hostStdio.stdin {
|
||||
let type = VsockType(
|
||||
port: stdinPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdinSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdinSocket.connect()
|
||||
$0.stdinSocket = stdinSocket
|
||||
}
|
||||
|
||||
if let stdoutPort = self.hostStdio.stdout {
|
||||
let type = VsockType(
|
||||
port: stdoutPort,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
let stdoutSocket = try Socket(type: type, closeOnDeinit: false)
|
||||
try stdoutSocket.connect()
|
||||
$0.stdoutSocket = stdoutSocket
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attach(pid: Int32, fd: Int32) throws {
|
||||
try self.state.withLock {
|
||||
let containerFd = CZ_pidfd_open(pid, 0)
|
||||
guard containerFd != -1 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
defer { Foundation.close(Int32(containerFd)) }
|
||||
|
||||
let hostFd = CZ_pidfd_getfd(containerFd, fd, 0)
|
||||
guard hostFd != -1 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
|
||||
let term = try Terminal(descriptor: Int32(hostFd), setInitState: false)
|
||||
$0.parent = term
|
||||
|
||||
if let stdinSocket = $0.stdinSocket {
|
||||
let pair = IOPair(
|
||||
readFrom: stdinSocket,
|
||||
writeTo: UnownedIOCloser(term),
|
||||
reason: "TerminalIO stdin",
|
||||
logger: log
|
||||
)
|
||||
try pair.relay(ignoreHup: true)
|
||||
$0.stdin = pair
|
||||
}
|
||||
|
||||
if let stdoutSocket = $0.stdoutSocket {
|
||||
let pair = IOPair(
|
||||
readFrom: term,
|
||||
writeTo: stdoutSocket,
|
||||
reason: "TerminalIO stdout",
|
||||
logger: log
|
||||
)
|
||||
try pair.relay(ignoreHup: true)
|
||||
$0.stdout = pair
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
self.state.withLock {
|
||||
// stdout must close before stdin because both IOPairs share the
|
||||
// Terminal fd. stdout registered that fd with epoll (as its read
|
||||
// source) and needs to unregister it while the fd is still valid.
|
||||
// stdin closes the Terminal as its write destination, which would
|
||||
// invalidate the fd before stdout can unregister.
|
||||
if let stdout = $0.stdout {
|
||||
stdout.close()
|
||||
$0.stdout = nil
|
||||
}
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
|
||||
// If IOPairs were never created (process exited before attach),
|
||||
// close the raw sockets directly since they have closeOnDeinit
|
||||
// disabled.
|
||||
if let stdinSocket = $0.stdinSocket {
|
||||
try? stdinSocket.close()
|
||||
$0.stdinSocket = nil
|
||||
}
|
||||
if let stdoutSocket = $0.stdoutSocket {
|
||||
try? stdoutSocket.close()
|
||||
$0.stdoutSocket = nil
|
||||
}
|
||||
|
||||
$0.parent = nil
|
||||
}
|
||||
}
|
||||
|
||||
// NOP
|
||||
func closeAfterExec() throws {}
|
||||
|
||||
func closeStdin() throws {
|
||||
self.state.withLock {
|
||||
if let stdin = $0.stdin {
|
||||
stdin.close()
|
||||
$0.stdin = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,414 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationIO
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import LCShim
|
||||
import Logging
|
||||
|
||||
actor VsockProxy {
|
||||
enum Action {
|
||||
case listen
|
||||
case dial
|
||||
}
|
||||
|
||||
private enum SocketType {
|
||||
case unix
|
||||
case vsock
|
||||
}
|
||||
|
||||
let id: String
|
||||
|
||||
private let path: URL
|
||||
private let action: Action
|
||||
private let port: UInt32
|
||||
private let udsPerms: UInt32?
|
||||
private let log: Logger?
|
||||
|
||||
private var listener: Socket?
|
||||
private var task: Task<(), Never>?
|
||||
private var connectionTasks: [UUID: Task<(), Never>] = [:]
|
||||
|
||||
init(
|
||||
id: String,
|
||||
action: Action,
|
||||
port: UInt32,
|
||||
path: URL,
|
||||
udsPerms: UInt32?,
|
||||
log: Logger? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.action = action
|
||||
self.port = port
|
||||
self.path = path
|
||||
self.udsPerms = udsPerms
|
||||
self.log = log
|
||||
}
|
||||
}
|
||||
|
||||
extension VsockProxy {
|
||||
func start() throws {
|
||||
guard listener == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
log?.debug(
|
||||
"starting proxy",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"uds": "\(path)",
|
||||
"action": "\(action)",
|
||||
])
|
||||
|
||||
switch action {
|
||||
case .dial:
|
||||
try dialHost()
|
||||
case .listen:
|
||||
try dialGuest()
|
||||
}
|
||||
}
|
||||
|
||||
func close() throws {
|
||||
guard let listener else {
|
||||
return
|
||||
}
|
||||
|
||||
log?.debug(
|
||||
"stopping proxy",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"uds": "\(path)",
|
||||
"action": "\(action)",
|
||||
])
|
||||
|
||||
try listener.close()
|
||||
|
||||
for (_, t) in connectionTasks { t.cancel() }
|
||||
connectionTasks.removeAll()
|
||||
|
||||
if action == .dial {
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: path.path) {
|
||||
try fm.removeItem(at: path)
|
||||
}
|
||||
}
|
||||
|
||||
task?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
private func dialHost() throws {
|
||||
let fm = FileManager.default
|
||||
|
||||
let parentDir = path.deletingLastPathComponent()
|
||||
try fm.createDirectory(
|
||||
at: parentDir,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let type = try UnixType(
|
||||
path: path.path,
|
||||
perms: udsPerms,
|
||||
unlinkExisting: true
|
||||
)
|
||||
let oldMask = umask(0)
|
||||
defer { umask(oldMask) }
|
||||
let uds = try Socket(type: type)
|
||||
try uds.listen()
|
||||
listener = uds
|
||||
|
||||
try acceptLoop(socketType: .unix)
|
||||
}
|
||||
|
||||
private func dialGuest() throws {
|
||||
let type = VsockType(
|
||||
port: port,
|
||||
cid: VsockType.anyCID
|
||||
)
|
||||
let vsock = try Socket(type: type)
|
||||
try vsock.listen()
|
||||
listener = vsock
|
||||
|
||||
try acceptLoop(socketType: .vsock)
|
||||
}
|
||||
|
||||
private func acceptLoop(socketType: SocketType) throws {
|
||||
guard let listener else {
|
||||
return
|
||||
}
|
||||
|
||||
let stream = try listener.acceptStream()
|
||||
let task = Task {
|
||||
do {
|
||||
for try await conn in stream {
|
||||
let connID = UUID()
|
||||
let connTask = Task {
|
||||
defer { self.connectionTasks[connID] = nil }
|
||||
log?.debug(
|
||||
"accepting connection",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"uds": "\(path)",
|
||||
"action": "\(action)",
|
||||
"socketType": "\(socketType)",
|
||||
])
|
||||
do {
|
||||
try await handleConn(
|
||||
conn: conn,
|
||||
connType: socketType
|
||||
)
|
||||
} catch {
|
||||
self.log?.error("failed to handle connection: \(error)")
|
||||
}
|
||||
}
|
||||
// Safe: actor serialization ensures this runs before connTask can execute its defer.
|
||||
connectionTasks[connID] = connTask
|
||||
}
|
||||
} catch {
|
||||
self.log?.error("failed to accept connection: \(error)")
|
||||
}
|
||||
}
|
||||
self.task = task
|
||||
}
|
||||
|
||||
private func handleConn(
|
||||
conn: ContainerizationOS.Socket,
|
||||
connType: SocketType
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, Error>) in
|
||||
do {
|
||||
// `relayTo` isn't used concurrently.
|
||||
nonisolated(unsafe) var relayTo: ContainerizationOS.Socket
|
||||
|
||||
switch connType {
|
||||
case .unix:
|
||||
let type = VsockType(
|
||||
port: port,
|
||||
cid: VsockType.hostCID
|
||||
)
|
||||
relayTo = try Socket(
|
||||
type: type,
|
||||
closeOnDeinit: false
|
||||
)
|
||||
case .vsock:
|
||||
let type = try UnixType(path: path.path)
|
||||
relayTo = try Socket(
|
||||
type: type,
|
||||
closeOnDeinit: false
|
||||
)
|
||||
}
|
||||
|
||||
try relayTo.connect()
|
||||
|
||||
// `clientFile` isn't used concurrently.
|
||||
nonisolated(unsafe) var clientFile = OSFile.SpliceFile(fd: conn.fileDescriptor)
|
||||
nonisolated(unsafe) var eofFromClient = false
|
||||
// `serverFile` isn't used concurrently.
|
||||
nonisolated(unsafe) var serverFile = OSFile.SpliceFile(fd: relayTo.fileDescriptor)
|
||||
nonisolated(unsafe) var eofFromServer = false
|
||||
|
||||
// clean up when any of these conditions apply:
|
||||
// - the client has completely hung up or errored
|
||||
// - the server has completely hung up or errored
|
||||
// - both the client and server have half closed via:
|
||||
// - read hangup on epoll
|
||||
// - EOF on splice
|
||||
let cleanup = { @Sendable [log, port, path, action] in
|
||||
log?.debug(
|
||||
"cleaning up",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"uds": "\(path)",
|
||||
"action": "\(action)",
|
||||
"eofFromClient": "\(eofFromClient)",
|
||||
"eofFromServer": "\(eofFromServer)",
|
||||
"clientFd": "\(clientFile.fileDescriptor)",
|
||||
"serverFd": "\(serverFile.fileDescriptor)",
|
||||
]
|
||||
)
|
||||
|
||||
do {
|
||||
try ProcessSupervisor.default.unregisterFd(clientFile.fileDescriptor)
|
||||
try ProcessSupervisor.default.unregisterFd(serverFile.fileDescriptor)
|
||||
try conn.close()
|
||||
try relayTo.close()
|
||||
} catch {
|
||||
self.log?.error("Failed to clean up vsock proxy: \(error)")
|
||||
}
|
||||
c.resume()
|
||||
}
|
||||
|
||||
try! ProcessSupervisor.default.registerFd(clientFile.fileDescriptor, mask: [.input, .output]) { mask in
|
||||
if mask.readyToRead && !eofFromClient {
|
||||
let (fromEof, toEof) = Self.transferData(
|
||||
fromFile: &clientFile,
|
||||
toFile: &serverFile,
|
||||
description: "readyToRead:toServer",
|
||||
log: self.log
|
||||
)
|
||||
eofFromClient = eofFromClient || fromEof
|
||||
eofFromServer = eofFromServer || toEof
|
||||
}
|
||||
|
||||
if mask.readyToWrite && !eofFromServer {
|
||||
let (fromEof, toEof) = Self.transferData(
|
||||
fromFile: &serverFile,
|
||||
toFile: &clientFile,
|
||||
description: "readyToWrite:toClient",
|
||||
log: self.log
|
||||
)
|
||||
eofFromClient = eofFromClient || toEof
|
||||
eofFromServer = eofFromServer || fromEof
|
||||
}
|
||||
|
||||
if mask.isHangup {
|
||||
eofFromClient = true
|
||||
eofFromServer = true
|
||||
} else if mask.isRemoteHangup && !eofFromClient {
|
||||
// half close, shut down client to server transfer
|
||||
// we should see no more EPOLLIN events on the client fd
|
||||
// and no more EPOLLOUT events on the server fd
|
||||
eofFromClient = true
|
||||
if shutdown(serverFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
|
||||
self.log?.warning(
|
||||
"failed to shut down client reads",
|
||||
metadata: [
|
||||
"vport": "\(self.port)",
|
||||
"uds": "\(self.path)",
|
||||
"errno": "\(errno)",
|
||||
"eofFromClient": "\(eofFromClient)",
|
||||
"eofFromServer": "\(eofFromServer)",
|
||||
"clientFd": "\(clientFile.fileDescriptor)",
|
||||
"serverFd": "\(serverFile.fileDescriptor)",
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if eofFromClient && eofFromServer {
|
||||
return cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
try! ProcessSupervisor.default.registerFd(serverFile.fileDescriptor, mask: [.input, .output]) { mask in
|
||||
if mask.readyToRead && !eofFromServer {
|
||||
let (fromEof, toEof) = Self.transferData(
|
||||
fromFile: &serverFile,
|
||||
toFile: &clientFile,
|
||||
description: "readyToRead:toClient",
|
||||
log: self.log
|
||||
)
|
||||
eofFromClient = eofFromClient || toEof
|
||||
eofFromServer = eofFromServer || fromEof
|
||||
}
|
||||
|
||||
if mask.readyToWrite && !eofFromClient {
|
||||
let (fromEof, toEof) = Self.transferData(
|
||||
fromFile: &clientFile,
|
||||
toFile: &serverFile,
|
||||
description: "readyToWrite:toServer",
|
||||
log: self.log
|
||||
)
|
||||
eofFromClient = eofFromClient || fromEof
|
||||
eofFromServer = eofFromServer || toEof
|
||||
}
|
||||
|
||||
if mask.isHangup {
|
||||
eofFromClient = true
|
||||
eofFromServer = true
|
||||
} else if mask.isRemoteHangup && !eofFromServer {
|
||||
// half close, shut down server to client transfer
|
||||
// we should see no more EPOLLIN events on the server fd
|
||||
// and no more EPOLLOUT events on the client fd
|
||||
eofFromServer = true
|
||||
if shutdown(clientFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
|
||||
self.log?.warning(
|
||||
"failed to shut down server reads",
|
||||
metadata: [
|
||||
"vport": "\(self.port)",
|
||||
"uds": "\(self.path)",
|
||||
"errno": "\(errno)",
|
||||
"eofFromClient": "\(eofFromClient)",
|
||||
"eofFromServer": "\(eofFromServer)",
|
||||
"clientFd": "\(clientFile.fileDescriptor)",
|
||||
"serverFd": "\(serverFile.fileDescriptor)",
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if eofFromClient && eofFromServer {
|
||||
return cleanup()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
c.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func transferData(
|
||||
fromFile: inout OSFile.SpliceFile,
|
||||
toFile: inout OSFile.SpliceFile,
|
||||
description: String,
|
||||
log: Logger?
|
||||
) -> (Bool, Bool) {
|
||||
do {
|
||||
let (readBytes, writeBytes, action) = try OSFile.splice(from: &fromFile, to: &toFile)
|
||||
log?.trace(
|
||||
"transferred data",
|
||||
metadata: [
|
||||
"description": "\(description)",
|
||||
"action": "\(action)",
|
||||
"readBytes": "\(readBytes)",
|
||||
"writeBytes": "\(writeBytes)",
|
||||
"fromFd": "\(fromFile.fileDescriptor)",
|
||||
"toFd": "\(toFile.fileDescriptor)",
|
||||
]
|
||||
)
|
||||
if action == .eof {
|
||||
// half close, shut down client to server transfer
|
||||
// we should see no more EPOLLIN events on the client fd
|
||||
// and no more EPOLLOUT events on the server fd
|
||||
if shutdown(toFile.fileDescriptor, Int32(SHUT_WR)) != 0 {
|
||||
log?.warning(
|
||||
"failed to shut down reads",
|
||||
metadata: [
|
||||
"description": "\(description)",
|
||||
"errno": "\(errno)",
|
||||
"action": "\(action)",
|
||||
"readBytes": "\(readBytes)",
|
||||
"writeBytes": "\(writeBytes)",
|
||||
"fromFd": "\(fromFile.fileDescriptor)",
|
||||
"toFd": "\(toFile.fileDescriptor)",
|
||||
]
|
||||
)
|
||||
}
|
||||
return (true, false)
|
||||
} else if action == .brokenPipe {
|
||||
return (true, true)
|
||||
}
|
||||
return (false, false)
|
||||
} catch {
|
||||
return (true, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user