From 5ab9c95adb3f3da25aeff7f53f2caa3f46c5c292 Mon Sep 17 00:00:00 2001 From: Jaewon Date: Sun, 3 May 2026 22:03:24 -0700 Subject: [PATCH 1/2] Add `native` property to LinuxProcess LinuxProcess with `native` property runs outside containerized environment. --- Sources/Containerization/LinuxContainer.swift | 3 ++- Sources/Containerization/LinuxProcess.swift | 10 +++++++++- Sources/Containerization/Vminitd.swift | 3 +++ vminitd/Sources/VminitdCore/ManagedContainer.swift | 3 ++- vminitd/Sources/VminitdCore/Server+GRPC.swift | 8 +++++++- 5 files changed, 23 insertions(+), 4 deletions(-) diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index c51cb622..b8a18d85 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -916,7 +916,7 @@ extension LinuxContainer { /// Execute a new process in the container. The process is not started after this call, and must be manually started /// via the `start` method. - public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess { + public func exec(_ id: String, configuration: LinuxProcessConfiguration, native: Bool = false) async throws -> LinuxProcess { try await self.state.withLock { var state = try $0.startedState("exec") @@ -935,6 +935,7 @@ extension LinuxContainer { containerID: self.id, spec: spec, io: stdio, + native: native, ociRuntimePath: self.config.ociRuntimePath, agent: agent, vm: state.vm, diff --git a/Sources/Containerization/LinuxProcess.swift b/Sources/Containerization/LinuxProcess.swift index 0d8300ba..4bcc83ed 100644 --- a/Sources/Containerization/LinuxProcess.swift +++ b/Sources/Containerization/LinuxProcess.swift @@ -94,6 +94,7 @@ public final class LinuxProcess: Sendable { private let state: Mutex private let ioSetup: Stdio + private let native: Bool private let agent: any VirtualMachineAgent private let vm: any VirtualMachineInstance private let ociRuntimePath: String? @@ -105,6 +106,7 @@ public final class LinuxProcess: Sendable { containerID: String? = nil, spec: Spec, io: Stdio, + native: Bool = false, ociRuntimePath: String?, agent: any VirtualMachineAgent, vm: any VirtualMachineInstance, @@ -115,6 +117,7 @@ public final class LinuxProcess: Sendable { self.owningContainer = containerID self.state = Mutex(.init(spec: spec, pid: -1, stdio: StdioHandles())) self.ioSetup = io + self.native = native self.agent = agent self.ociRuntimePath = ociRuntimePath self.vm = vm @@ -240,6 +243,11 @@ extension LinuxProcess { do { let spec = self.state.withLock { $0.spec } var listeners = [VsockListener?](repeating: nil, count: 3) + + let options = try JSONEncoder().encode( + CreateProcessOptions(native: self.native) + ) + if let stdin = self.ioSetup.stdin { listeners[0] = try self.vm.listen(stdin.port) } @@ -268,7 +276,7 @@ extension LinuxProcess { stderrPort: self.ioSetup.stderr?.port, ociRuntimePath: self.ociRuntimePath, configuration: spec, - options: nil + options: options ) let result = try await t.value diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index bf62a034..efc19fee 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -231,6 +231,9 @@ extension Vminitd: VirtualMachineAgent { if let ociRuntimePath { $0.ociRuntimePath = ociRuntimePath } + if let options { + $0.options = options + } $0.configuration = try enc.encode(configuration) }) } diff --git a/vminitd/Sources/VminitdCore/ManagedContainer.swift b/vminitd/Sources/VminitdCore/ManagedContainer.swift index ff8eeea9..689e7b02 100644 --- a/vminitd/Sources/VminitdCore/ManagedContainer.swift +++ b/vminitd/Sources/VminitdCore/ManagedContainer.swift @@ -160,7 +160,8 @@ extension ManagedContainer { func createExec( id: String, stdio: HostStdio, - process: ContainerizationOCI.Process + process: ContainerizationOCI.Process, + native: Bool ) throws { log.debug("creating exec process with \(process)") diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index 23cca9f8..b3c9b728 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -789,12 +789,18 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ terminal: process.terminal ) + let options = try JSONDecoder().decode( + CreateProcessOptions.self, + from: request.options + ) + // This is an exec. if let container = await self.state.containers[request.containerID] { try await container.createExec( id: request.id, stdio: stdioPorts, - process: process + process: process, + native: options.native ) } else { // We need to make our new fangled container. From 0aa3d507360291e530c50b08c2cdcb6b3d69ba7c Mon Sep 17 00:00:00 2001 From: Jaewon Date: Mon, 4 May 2026 00:11:35 -0700 Subject: [PATCH 2/2] Implement NativeProcess NativeProcess is a ContainerProcess that runs outside the containerized environment. It's equally managed like the other ManagedProcess, but only runs outside sandbox. --- .../CreateProcessOptions.swift | 34 +++ Sources/Containerization/LinuxContainer.swift | 3 +- .../VminitdCore/ManagedContainer.swift | 26 ++- .../Sources/VminitdCore/NativeProcess.swift | 206 ++++++++++++++++++ 4 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 Sources/Containerization/CreateProcessOptions.swift create mode 100644 vminitd/Sources/VminitdCore/NativeProcess.swift diff --git a/Sources/Containerization/CreateProcessOptions.swift b/Sources/Containerization/CreateProcessOptions.swift new file mode 100644 index 00000000..e59f0512 --- /dev/null +++ b/Sources/Containerization/CreateProcessOptions.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container 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. +//===----------------------------------------------------------------------===// + +public struct CreateProcessOptions: Sendable, Codable { + /// The process is created outside containerized env. + public var native: Bool + + enum CodingKeys: String, CodingKey { + case native + } + + public init(native: Bool) { + self.native = native + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + native = try container.decodeIfPresent(Bool.self, forKey: .native) ?? false + } +} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index b8a18d85..bc7cf5d9 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -849,6 +849,7 @@ extension LinuxContainer { public func kill(_ signal: Signal) async throws { try await self.state.withLock { let state = try $0.startedState("kill") + try await state.process.kill(signal) } } @@ -915,7 +916,7 @@ extension LinuxContainer { } /// Execute a new process in the container. The process is not started after this call, and must be manually started - /// via the `start` method. + /// via the `start` method. When `native` is true, the process is created outside of container, running in the root of VM. public func exec(_ id: String, configuration: LinuxProcessConfiguration, native: Bool = false) async throws -> LinuxProcess { try await self.state.withLock { var state = try $0.startedState("exec") diff --git a/vminitd/Sources/VminitdCore/ManagedContainer.swift b/vminitd/Sources/VminitdCore/ManagedContainer.swift index 689e7b02..2ee3da05 100644 --- a/vminitd/Sources/VminitdCore/ManagedContainer.swift +++ b/vminitd/Sources/VminitdCore/ManagedContainer.swift @@ -171,14 +171,24 @@ extension ManagedContainer { 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 + let exec: ContainerProcess + if native { + exec = try NativeProcess( + id: id, + stdio: stdio, + process: process, + log: self.log + ) + } else { + exec = try ManagedProcess( + id: id, + stdio: stdio, + bundle: self.bundle, + owningPid: self.initProcess.pid, + log: self.log + ) + } + self.execs[id] = exec } func start(execID: String) async throws -> Int32 { diff --git a/vminitd/Sources/VminitdCore/NativeProcess.swift b/vminitd/Sources/VminitdCore/NativeProcess.swift new file mode 100644 index 00000000..c50efcb0 --- /dev/null +++ b/vminitd/Sources/VminitdCore/NativeProcess.swift @@ -0,0 +1,206 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging +import Synchronization +import SystemPackage + +final class NativeProcess: ContainerProcess, Sendable { + private struct State { + init(io: ManagedProcess.IO) { + self.io = io + } + + var waiters: [CheckedContinuation] = [] + var exitStatus: ContainerExitStatus? = nil + var pid: Int32? + let io: ManagedProcess.IO + } + + let id: String + + private let log: Logger + private let command: Command + private let state: Mutex + + var pid: Int32? { + self.state.withLock { + $0.pid + } + } + + init( + id: String, + stdio: HostStdio, + process: ContainerizationOCI.Process, + log: Logger + ) throws { + self.id = id + var log = log + log[metadataKey: "id"] = "\(id)" + self.log = log + + guard !process.args.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "process args cannot be empty") + } + + let executableArg = process.args[0] + guard executableArg.hasPrefix("/") else { + throw ContainerizationError(.invalidArgument, message: "executable path must be absolute path") + } + + let executable = FilePath(executableArg) + guard FileManager.default.fileExists(atPath: executable.string) else { + throw ContainerizationError(.invalidArgument, message: "failed to find target executable \(executableArg)") + } + + var command = Command( + executable.string, + arguments: Array(process.args.dropFirst()), + environment: process.env, + directory: process.cwd + ) + + guard !stdio.terminal else { + throw ContainerizationError(.invalidArgument, message: "native process doesn't support terminal") + } + + command.attrs = .init(setsid: false) + let 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.state = Mutex(State(io: io)) + } + + func start() async throws -> Int32 { + do { + return try self.state.withLock { + log.info( + "starting native process", + metadata: ["id": "\(id)"] + ) + + try command.start() + try $0.io.closeAfterExec() + + let pid = command.pid + $0.pid = pid + + log.info( + "started native process", + metadata: [ + "pid": "\(pid)", + "id": "\(id)", + ] + ) + + return pid + } + } catch { + throw ContainerizationError( + .internalError, + message: "native process failed to start: \(error)" + ) + } + } + + func setExit(_ status: Int32) { + self.state.withLock { state in + self.log.info( + "native 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) native process waiters signaled") + state.waiters.removeAll() + } + } + + 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 native 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 { + // Nothing to be done + } + +}