chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
@@ -0,0 +1,24 @@
/*
* 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.
*/
#ifndef CZ_VERSION_H
#define CZ_VERSION_H
const char* CZ_get_git_commit(void);
const char* CZ_get_git_tag(void);
const char* CZ_get_build_time(void);
#endif
+41
View File
@@ -0,0 +1,41 @@
/*
* 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.
*/
#include "version.h"
#ifndef GIT_COMMIT
#define GIT_COMMIT "unspecified"
#endif
#ifndef GIT_TAG
#define GIT_TAG ""
#endif
#ifndef BUILD_TIME
#define BUILD_TIME "unspecified"
#endif
const char* CZ_get_git_commit(void) {
return GIT_COMMIT;
}
const char* CZ_get_git_tag(void) {
return GIT_TAG;
}
const char* CZ_get_build_time(void) {
return BUILD_TIME;
}
+770
View File
@@ -0,0 +1,770 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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)
// NOTE: Ideally this should live in ContainerizationOS/Linux, or just ContainerizationCgroups
// or something similar, but it's not there yet. It does what we need, but it'd need a lot more
// features and testing before it's ready to be public.
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
import LCShim
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
package enum Cgroup2Controller: String {
case pids
case memory
case cpuset
case cpu
case io
case hugetlb
}
// Extremely simple cgroup manager. Our needs are simple for now, and this is
// reflected in the type.
public struct Cgroup2Manager: Sendable {
public static let defaultMountPoint = URL(filePath: "/sys/fs/cgroup")
private static let killFile = "cgroup.kill"
private static let procsFile = "cgroup.procs"
private static let subtreeControlFile = "cgroup.subtree_control"
private static let cg2Magic = 0x6367_7270
private let mountPoint: URL
private let path: URL
private let logger: Logger?
package init(
mountPoint: URL = Self.defaultMountPoint,
group: URL,
logger: Logger? = nil
) {
self.mountPoint = mountPoint
self.path = mountPoint.appending(path: group.path)
self.logger = logger
}
public static func load(
mountPoint: URL = Self.defaultMountPoint,
group: URL,
logger: Logger? = nil
) throws -> Cgroup2Manager {
let path = mountPoint.appending(path: group.path)
var s = statfs()
let res = statfs(path.path, &s)
if res != 0 {
throw Error.errno(errno: errno, message: "failed to statfs \(path.path)")
}
if Int64(s.f_type) != Self.cg2Magic {
throw Error.notCgroup
}
return Cgroup2Manager(
mountPoint: mountPoint,
group: group,
logger: logger
)
}
package static func loadFromPid(pid: Int32, logger: Logger? = nil) throws -> Cgroup2Manager {
let procCgPath = URL(filePath: "/proc/\(pid)/cgroup")
let fh = try FileHandle(forReadingFrom: procCgPath)
guard let data = try fh.readToEnd() else {
throw Error.errno(errno: errno, message: "failed to read \(procCgPath)")
}
// If this fails we have bigger problems.
let str = String(data: data, encoding: .utf8)!
let parts = str.split(separator: ":")
if parts[0] != "0" {
throw Error.cgroup1
}
// We should really read /proc/pid/mountinfo, but for now just assume
// it's always at /sys/fs/cgroup.
let path = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
return Cgroup2Manager(group: URL(filePath: String(path)), logger: logger)
}
package func create(perms: Int16 = 0o755) throws {
self.logger?.info(
"creating cgroup manager",
metadata: [
"mountpoint": "\(self.mountPoint.path)",
"path": "\(self.path.path)",
])
try FileManager.default.createDirectory(
at: self.path,
withIntermediateDirectories: true,
attributes: [.posixPermissions: perms]
)
}
private static func writeValue(path: URL, value: String, fileName: String) throws {
let file = path.appending(path: fileName)
let fd = open(file.path, O_WRONLY, 0)
if fd == -1 {
throw Error.errno(errno: errno, message: "failed to open \(file.path)")
}
defer { close(fd) }
let bytes = Array(value.utf8)
let res = Syscall.retrying {
bytes.withUnsafeBytes { write(fd, $0.baseAddress!, bytes.count) }
}
if res == -1 {
throw Error.errno(errno: errno, message: "failed to write to \(file.path)")
}
}
package func toggleSubtreeControllers(controllers: [Cgroup2Controller], enable: Bool) throws {
let value = controllers.map { (enable ? "+" : "-") + $0.rawValue }.joined(separator: " ")
let mountComponents = self.mountPoint.pathComponents
let pathComponents = self.path.pathComponents
// First ensure it's set on the root.
var current = self.mountPoint
try Self.writeValue(
path: current,
value: value,
fileName: Self.subtreeControlFile
)
// Toggle everything except the leaf, as otherwise we won't be able to write
// to cgroup.procs, and what fun is that :)
if mountComponents.count < pathComponents.count - 1 {
for i in mountComponents.count...pathComponents.count - 2 {
current = current.appending(path: pathComponents[i])
try Self.writeValue(
path: current,
value: value,
fileName: Self.subtreeControlFile
)
}
}
}
package func toggleAllAvailableControllers(enable: Bool) throws {
// Read available controllers from cgroup.controllers
let controllersFile = self.mountPoint.appending(path: "cgroup.controllers")
let controllersContent = try String(contentsOf: controllersFile, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
// Parse controller names and convert to our enum
let availableControllers =
controllersContent
.split(separator: " ")
.compactMap { Cgroup2Controller(rawValue: String($0)) }
if !availableControllers.isEmpty {
try toggleSubtreeControllers(controllers: availableControllers, enable: enable)
}
}
public func addProcess(pid: Int32) throws {
self.logger?.debug(
"adding new proc to cgroup",
metadata: [
"mountpoint": "\(self.mountPoint.path)",
"path": "\(self.path.path)",
])
let pidStr = String(pid)
try Self.writeValue(
path: self.path,
value: pidStr,
fileName: Self.procsFile
)
}
public func applyResources(resources: ContainerizationOCI.LinuxResources) throws {
self.logger?.debug(
"applying cgroup resources",
metadata: [
"path": "\(self.path.path)"
])
if let memory = resources.memory, let limit = memory.limit {
// The OCI spec defines -1 as unlimited; cgroup v2 expects "max".
let value = limit < 0 ? "max" : String(limit)
try Self.writeValue(
path: self.path,
value: value,
fileName: "memory.max"
)
}
if let cpu = resources.cpu, let quota = cpu.quota, let period = cpu.period {
// cpu.max format is "quota period"
let value = "\(quota) \(period)"
try Self.writeValue(
path: self.path,
value: value,
fileName: "cpu.max"
)
}
if let pids = resources.pids {
// The OCI spec defines -1 as unlimited; cgroup v2 expects "max".
let value = pids.limit < 0 ? "max" : String(pids.limit)
try Self.writeValue(
path: self.path,
value: value,
fileName: "pids.max"
)
}
}
package func setMemoryHigh(bytes: UInt64) throws {
self.logger?.debug(
"setting memory.high",
metadata: [
"path": "\(self.path.path)",
"bytes": "\(bytes)",
])
try Self.writeValue(
path: self.path,
value: String(bytes),
fileName: "memory.high"
)
}
package func setMemoryLow(bytes: UInt64) throws {
self.logger?.debug(
"setting memory.low",
metadata: [
"path": "\(self.path.path)",
"bytes": "\(bytes)",
]
)
try Self.writeValue(
path: self.path,
value: String(bytes),
fileName: "memory.low")
}
package func getMemoryEvents() throws -> MemoryEvents {
let content = try readFileContent(fileName: "memory.events")
let values = parseKeyValuePairs(content)
return MemoryEvents(
low: values["low"] ?? 0,
high: values["high"] ?? 0,
max: values["max"] ?? 0,
oom: values["oom"] ?? 0,
oomKill: values["oom_kill"] ?? 0
)
}
package func getMemoryEventsPath() -> String {
self.path.appending(path: "memory.events").path
}
package func kill() throws {
try Self.writeValue(
path: self.path,
value: "1",
fileName: Self.killFile
)
}
package func delete(force: Bool = false) throws {
self.logger?.info(
"deleting cgroup manager",
metadata: [
"mountpoint": "\(self.mountPoint.path)",
"path": "\(self.path.path)",
])
if force {
try self.kill()
}
// Recursively remove child cgroups first
try removeChildCgroups(at: self.path, force: force)
let result = rmdir(self.path.path)
if result != 0 {
throw Error.errno(errno: errno, message: "failed to remove cgroup directory \(self.path.path)")
}
}
private func removeChildCgroups(at path: URL, force: Bool) throws {
let fileManager = FileManager.default
guard let contents = try? fileManager.contentsOfDirectory(atPath: path.path) else {
return
}
// Remove child directories (potential nested cgroups) first
for item in contents {
let childPath = path.appending(path: item)
var isDirectory: ObjCBool = false
if fileManager.fileExists(atPath: childPath.path, isDirectory: &isDirectory) && isDirectory.boolValue {
if force {
try Self.writeValue(
path: childPath,
value: "1",
fileName: Self.killFile
)
}
try removeChildCgroups(at: childPath, force: force)
let result = rmdir(childPath.path)
if result != 0 {
throw Error.errno(errno: errno, message: "failed to remove child cgroup \(childPath.path)")
}
}
}
}
package func stats(_ categories: Cgroup2StatsCategory = .all) throws -> Cgroup2Stats {
Cgroup2Stats(
pids: categories.contains(.pids) ? try self.readPidsStats() : nil,
memory: categories.contains(.memory) ? try self.readMemoryStats() : nil,
cpu: categories.contains(.cpu) ? try self.readCPUStats() : nil,
io: categories.contains(.io) ? try self.readIOStats() : nil
)
}
private func readFileContent(fileName: String) throws -> String? {
let filePath = self.path.appending(path: fileName)
guard FileManager.default.fileExists(atPath: filePath.path) else {
return nil
}
return try String(contentsOf: filePath, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func parseSingleValue(_ content: String?) -> UInt64? {
guard let content = content, !content.isEmpty else { return nil }
if content == "max" {
return UInt64.max
}
return UInt64(content)
}
private func parseKeyValuePairs(_ content: String?) -> [String: UInt64] {
guard let content = content else { return [:] }
var result: [String: UInt64] = [:]
for line in content.components(separatedBy: .newlines) {
let parts = line.components(separatedBy: .whitespaces)
if parts.count == 2, let value = UInt64(parts[1]) {
result[parts[0]] = value
}
}
return result
}
private func readPidsStats() throws -> PidsStats? {
guard let currentContent = try readFileContent(fileName: "pids.current"),
let current = parseSingleValue(currentContent)
else {
return nil
}
let maxContent = try readFileContent(fileName: "pids.max")
let max = parseSingleValue(maxContent)
return PidsStats(current: current, max: max)
}
private func readMemoryStats() throws -> MemoryStats? {
guard let usageContent = try readFileContent(fileName: "memory.current"),
let usage = parseSingleValue(usageContent)
else {
return nil
}
let usageLimit = parseSingleValue(try readFileContent(fileName: "memory.max"))
let swapUsage = parseSingleValue(try readFileContent(fileName: "memory.swap.current"))
let swapLimit = parseSingleValue(try readFileContent(fileName: "memory.swap.max"))
let statContent = try readFileContent(fileName: "memory.stat")
let statValues = parseKeyValuePairs(statContent)
return MemoryStats(
usage: usage,
usageLimit: usageLimit,
swapUsage: swapUsage,
swapLimit: swapLimit,
anon: statValues["anon"] ?? 0,
file: statValues["file"] ?? 0,
kernelStack: statValues["kernel_stack"] ?? 0,
slab: statValues["slab"] ?? 0,
sock: statValues["sock"] ?? 0,
shmem: statValues["shmem"] ?? 0,
fileMapped: statValues["file_mapped"] ?? 0,
fileDirty: statValues["file_dirty"] ?? 0,
fileWriteback: statValues["file_writeback"] ?? 0,
pgfault: statValues["pgfault"] ?? 0,
pgmajfault: statValues["pgmajfault"] ?? 0,
workingsetRefaultAnon: statValues["workingset_refault_anon"] ?? 0,
workingsetRefaultFile: statValues["workingset_refault_file"] ?? 0,
workingsetActivate: statValues["workingset_activate"] ?? 0,
workingsetNodereclaim: statValues["workingset_nodereclaim"] ?? 0,
pgstealKswapd: statValues["pgsteal_kswapd"] ?? 0,
pgstealDirect: statValues["pgsteal_direct"] ?? 0,
pgstealKhugepaged: statValues["pgsteal_khugepaged"] ?? 0,
inactiveAnon: statValues["inactive_anon"] ?? 0,
activeAnon: statValues["active_anon"] ?? 0,
inactiveFile: statValues["inactive_file"] ?? 0,
activeFile: statValues["active_file"] ?? 0
)
}
private func readCPUStats() throws -> CPUStats? {
let statContent = try readFileContent(fileName: "cpu.stat")
let statValues = parseKeyValuePairs(statContent)
guard !statValues.isEmpty else {
return nil
}
return CPUStats(
usageUsec: statValues["usage_usec"] ?? 0,
userUsec: statValues["user_usec"] ?? 0,
systemUsec: statValues["system_usec"] ?? 0,
nrPeriods: statValues["nr_periods"] ?? 0,
nrThrottled: statValues["nr_throttled"] ?? 0,
throttledUsec: statValues["throttled_usec"] ?? 0
)
}
private func readIOStats() throws -> IOStats? {
guard let statContent = try readFileContent(fileName: "io.stat") else {
return IOStats(entries: [])
}
var entries: [IOEntry] = []
for line in statContent.components(separatedBy: .newlines) {
guard !line.isEmpty else { continue }
let parts = line.components(separatedBy: .whitespaces)
guard parts.count >= 2 else { continue }
let deviceParts = parts[0].components(separatedBy: ":")
guard deviceParts.count == 2,
let major = UInt64(deviceParts[0]),
let minor = UInt64(deviceParts[1])
else {
continue
}
var rbytes: UInt64 = 0
var wbytes: UInt64 = 0
var rios: UInt64 = 0
var wios: UInt64 = 0
var dbytes: UInt64 = 0
var dios: UInt64 = 0
for i in 1..<parts.count {
let keyValue = parts[i].components(separatedBy: "=")
guard keyValue.count == 2, let value = UInt64(keyValue[1]) else { continue }
switch keyValue[0] {
case "rbytes":
rbytes = value
case "wbytes":
wbytes = value
case "rios":
rios = value
case "wios":
wios = value
case "dbytes":
dbytes = value
case "dios":
dios = value
default:
break
}
}
entries.append(
IOEntry(
major: major,
minor: minor,
rbytes: rbytes,
wbytes: wbytes,
rios: rios,
wios: wios,
dbytes: dbytes,
dios: dios
))
}
return IOStats(entries: entries)
}
}
// Selects which cgroup stat groups to read.
package struct Cgroup2StatsCategory: OptionSet, Sendable {
package let rawValue: UInt8
package init(rawValue: UInt8) {
self.rawValue = rawValue
}
package static let pids = Cgroup2StatsCategory(rawValue: 1 << 0)
package static let memory = Cgroup2StatsCategory(rawValue: 1 << 1)
package static let cpu = Cgroup2StatsCategory(rawValue: 1 << 2)
package static let io = Cgroup2StatsCategory(rawValue: 1 << 3)
package static let all: Cgroup2StatsCategory = [.pids, .memory, .cpu, .io]
}
package struct Cgroup2Stats: Sendable {
package var pids: PidsStats?
package var memory: MemoryStats?
package var cpu: CPUStats?
package var io: IOStats?
package init(
pids: PidsStats? = nil,
memory: MemoryStats? = nil,
cpu: CPUStats? = nil,
io: IOStats? = nil
) {
self.pids = pids
self.memory = memory
self.cpu = cpu
self.io = io
}
}
package struct PidsStats: Sendable {
package var current: UInt64
package var max: UInt64?
package init(current: UInt64, max: UInt64? = nil) {
self.current = current
self.max = max
}
}
package struct MemoryStats: Sendable {
package var usage: UInt64
package var usageLimit: UInt64?
package var swapUsage: UInt64?
package var swapLimit: UInt64?
package var anon: UInt64
package var file: UInt64
package var kernelStack: UInt64
package var slab: UInt64
package var sock: UInt64
package var shmem: UInt64
package var fileMapped: UInt64
package var fileDirty: UInt64
package var fileWriteback: UInt64
package var pgfault: UInt64
package var pgmajfault: UInt64
package var workingsetRefaultAnon: UInt64
package var workingsetRefaultFile: UInt64
package var workingsetActivate: UInt64
package var workingsetNodereclaim: UInt64
package var pgstealKswapd: UInt64
package var pgstealDirect: UInt64
package var pgstealKhugepaged: UInt64
package var inactiveAnon: UInt64
package var activeAnon: UInt64
package var inactiveFile: UInt64
package var activeFile: UInt64
package init(
usage: UInt64,
usageLimit: UInt64? = nil,
swapUsage: UInt64? = nil,
swapLimit: UInt64? = nil,
anon: UInt64 = 0,
file: UInt64 = 0,
kernelStack: UInt64 = 0,
slab: UInt64 = 0,
sock: UInt64 = 0,
shmem: UInt64 = 0,
fileMapped: UInt64 = 0,
fileDirty: UInt64 = 0,
fileWriteback: UInt64 = 0,
pgfault: UInt64 = 0,
pgmajfault: UInt64 = 0,
workingsetRefaultAnon: UInt64 = 0,
workingsetRefaultFile: UInt64 = 0,
workingsetActivate: UInt64 = 0,
workingsetNodereclaim: UInt64 = 0,
pgstealKswapd: UInt64 = 0,
pgstealDirect: UInt64 = 0,
pgstealKhugepaged: UInt64 = 0,
inactiveAnon: UInt64 = 0,
activeAnon: UInt64 = 0,
inactiveFile: UInt64 = 0,
activeFile: UInt64 = 0
) {
self.usage = usage
self.usageLimit = usageLimit
self.swapUsage = swapUsage
self.swapLimit = swapLimit
self.anon = anon
self.file = file
self.kernelStack = kernelStack
self.slab = slab
self.sock = sock
self.shmem = shmem
self.fileMapped = fileMapped
self.fileDirty = fileDirty
self.fileWriteback = fileWriteback
self.pgfault = pgfault
self.pgmajfault = pgmajfault
self.workingsetRefaultAnon = workingsetRefaultAnon
self.workingsetRefaultFile = workingsetRefaultFile
self.workingsetActivate = workingsetActivate
self.workingsetNodereclaim = workingsetNodereclaim
self.pgstealKswapd = pgstealKswapd
self.pgstealDirect = pgstealDirect
self.pgstealKhugepaged = pgstealKhugepaged
self.inactiveAnon = inactiveAnon
self.activeAnon = activeAnon
self.inactiveFile = inactiveFile
self.activeFile = activeFile
}
}
package struct CPUStats: Sendable {
package var usageUsec: UInt64
package var userUsec: UInt64
package var systemUsec: UInt64
package var nrPeriods: UInt64
package var nrThrottled: UInt64
package var throttledUsec: UInt64
package init(
usageUsec: UInt64 = 0,
userUsec: UInt64 = 0,
systemUsec: UInt64 = 0,
nrPeriods: UInt64 = 0,
nrThrottled: UInt64 = 0,
throttledUsec: UInt64 = 0
) {
self.usageUsec = usageUsec
self.userUsec = userUsec
self.systemUsec = systemUsec
self.nrPeriods = nrPeriods
self.nrThrottled = nrThrottled
self.throttledUsec = throttledUsec
}
}
package struct IOStats: Sendable {
package var entries: [IOEntry]
package init(entries: [IOEntry] = []) {
self.entries = entries
}
}
package struct IOEntry: Sendable {
package var major: UInt64
package var minor: UInt64
package var rbytes: UInt64
package var wbytes: UInt64
package var rios: UInt64
package var wios: UInt64
package var dbytes: UInt64
package var dios: UInt64
package init(
major: UInt64,
minor: UInt64,
rbytes: UInt64 = 0,
wbytes: UInt64 = 0,
rios: UInt64 = 0,
wios: UInt64 = 0,
dbytes: UInt64 = 0,
dios: UInt64 = 0
) {
self.major = major
self.minor = minor
self.rbytes = rbytes
self.wbytes = wbytes
self.rios = rios
self.wios = wios
self.dbytes = dbytes
self.dios = dios
}
}
package struct MemoryEvents: Sendable {
package var low: UInt64
package var high: UInt64
package var max: UInt64
package var oom: UInt64
package var oomKill: UInt64
package init(
low: UInt64 = 0,
high: UInt64 = 0,
max: UInt64 = 0,
oom: UInt64 = 0,
oomKill: UInt64 = 0
) {
self.low = low
self.high = high
self.max = max
self.oom = oom
self.oomKill = oomKill
}
}
extension Cgroup2Manager {
package enum Error: Swift.Error, CustomStringConvertible {
case notCgroup
case cgroup1
case errno(errno: Int32, message: String)
case notExist(path: String)
package var description: String {
switch self {
case .errno(let errno, let message):
return "failed with errno \(errno): \(message)"
case .notExist(let path):
return "cgroup at path \(path) does not exist"
case .cgroup1:
return "tried to load a cgroup v1 path"
case .notCgroup:
return "path is not a cgroup mountpoint"
}
}
}
}
#endif
+104
View File
@@ -0,0 +1,104 @@
/*
* Copyright © 2025-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.
*/
// The below fall into two main categories:
// 1. Aren't exposed by Swifts glibc modulemap.
// 2. Don't have syscall wrappers/definitions in glibc/musl.
#ifndef __SYSCALL_H
#define __SYSCALL_H
#include <sys/types.h>
#ifdef __linux__
#include <sys/vfs.h>
#endif
// CLONE_* flags
#ifndef CLONE_NEWNS
#define CLONE_NEWNS 0x00020000
#endif
#ifndef CLONE_NEWCGROUP
#define CLONE_NEWCGROUP 0x02000000
#endif
#ifndef CLONE_NEWUTS
#define CLONE_NEWUTS 0x04000000
#endif
#ifndef CLONE_NEWIPC
#define CLONE_NEWIPC 0x08000000
#endif
#ifndef CLONE_NEWUSER
#define CLONE_NEWUSER 0x10000000
#endif
#ifndef CLONE_NEWPID
#define CLONE_NEWPID 0x20000000
#endif
extern int setns(int fd, int nstype);
extern int unshare(int flags);
extern int dup3(int oldfd, int newfd, int flags);
extern int execvpe(const char *file, char *const argv[], char *const envp[]);
extern int unlockpt(int fd);
extern char *ptsname(int fd);
// splice(2) and flags.
extern ssize_t splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out,
size_t len, unsigned int flags);
#ifndef SPLICE_F_MOVE
#define SPLICE_F_MOVE 1
#endif
#ifndef SPLICE_F_NONBLOCK
#define SPLICE_F_NONBLOCK 2
#endif
// RLIMIT constants as plain integers. On glibc these are __rlimit_resource
// enum values which can't be used as Int32 in Swift.
#define CZ_RLIMIT_CPU 0
#define CZ_RLIMIT_FSIZE 1
#define CZ_RLIMIT_DATA 2
#define CZ_RLIMIT_STACK 3
#define CZ_RLIMIT_CORE 4
#define CZ_RLIMIT_RSS 5
#define CZ_RLIMIT_NPROC 6
#define CZ_RLIMIT_NOFILE 7
#define CZ_RLIMIT_MEMLOCK 8
#define CZ_RLIMIT_AS 9
#define CZ_RLIMIT_LOCKS 10
#define CZ_RLIMIT_SIGPENDING 11
#define CZ_RLIMIT_MSGQUEUE 12
#define CZ_RLIMIT_NICE 13
#define CZ_RLIMIT_RTPRIO 14
#define CZ_RLIMIT_RTTIME 15
// setrlimit wrapper that accepts plain int for the resource parameter,
// avoiding glibc's __rlimit_resource enum type mismatch in Swift.
int CZ_setrlimit(int resource, unsigned long long soft, unsigned long long hard);
int CZ_pivot_root(const char *new_root, const char *put_old);
int CZ_set_sub_reaper();
#ifndef SYS_pidfd_open
#define SYS_pidfd_open 434
#endif
int CZ_pidfd_open(pid_t pid, unsigned int flags);
#ifndef SYS_pidfd_getfd
#define SYS_pidfd_getfd 438
#endif
int CZ_pidfd_getfd(int pidfd, int targetfd, unsigned int flags);
int CZ_prctl_set_no_new_privs();
#endif
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright © 2025-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.
*/
#ifdef __linux__
#include <sys/prctl.h>
#include <sys/resource.h>
#include <sys/syscall.h>
#include <unistd.h>
#include "syscall.h"
int CZ_pivot_root(const char *new_root, const char *put_old) {
return syscall(SYS_pivot_root, new_root, put_old);
}
int CZ_set_sub_reaper() { return prctl(PR_SET_CHILD_SUBREAPER, 1); }
int CZ_pidfd_open(pid_t pid, unsigned int flags) {
// Musl doesn't have pidfd_open.
return syscall(SYS_pidfd_open, pid, flags);
}
int CZ_pidfd_getfd(int pidfd, int targetfd, unsigned int flags) {
// Musl doesn't have pidfd_getfd.
return syscall(SYS_pidfd_getfd, pidfd, targetfd, flags);
}
int CZ_prctl_set_no_new_privs() {
return prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
}
int CZ_setrlimit(int resource, unsigned long long soft,
unsigned long long hard) {
struct rlimit limit;
limit.rlim_cur = (rlim_t)soft;
limit.rlim_max = (rlim_t)hard;
return setrlimit(resource, &limit);
}
#endif
@@ -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
+189
View File
@@ -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
+117
View File
@@ -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
+132
View File
@@ -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
+794
View File
@@ -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
+145
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 FoundationEssentials
import LCShim
#if canImport(Musl)
import Musl
private let _close = Musl.close
#elseif canImport(Glibc)
import Glibc
private let _close = Glibc.close
#endif
class Console {
let master: Int32
let slavePath: String
init() throws {
let masterFD = open("/dev/ptmx", O_RDWR | O_NOCTTY | O_CLOEXEC)
guard masterFD != -1 else {
throw App.Errno(stage: "open_ptmx")
}
guard unlockpt(masterFD) == 0 else {
throw App.Errno(stage: "unlockpt")
}
guard let slavePath = ptsname(masterFD) else {
throw App.Errno(stage: "ptsname")
}
self.master = masterFD
self.slavePath = String(cString: slavePath)
}
func configureStdIO() throws {
let path = self.slavePath
let slaveFD = open(path, O_RDWR)
guard slaveFD != -1 else {
throw App.Errno(stage: "open_pts")
}
defer { _ = _close(slaveFD) }
for fd: Int32 in 0...2 {
guard dup3(slaveFD, fd, 0) != -1 else {
throw App.Errno(stage: "dup3")
}
}
}
func close() throws {
guard _close(self.master) == 0 else {
throw App.Errno(stage: "close")
}
}
}
+165
View File
@@ -0,0 +1,165 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ArgumentParser
import ContainerizationOCI
import ContainerizationOS
import FoundationEssentials
import LCShim
import Logging
import SystemPackage
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
struct ExecCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "exec",
abstract: "Exec in a container"
)
@Option(name: .long, help: "path to an OCI runtime spec process configuration")
var processPath: String
@Option(name: .long, help: "pid of the init process for the container")
var parentPid: Int
func run() throws {
do {
let src = URL(fileURLWithPath: processPath)
let processBytes = try Data(contentsOf: src)
let process = try JSONDecoder().decode(
ContainerizationOCI.Process.self,
from: processBytes
)
try execInNamespaces(process: process)
} catch {
App.writeError(error)
throw error
}
}
static func enterNS(pidFd: Int32, nsType: Int32) throws {
guard setns(pidFd, nsType) == 0 else {
throw App.Errno(stage: "setns(fd)")
}
}
private func execInNamespaces(process: ContainerizationOCI.Process) throws {
let syncPipe = FileDescriptor(rawValue: 3)
let ackPipe = FileDescriptor(rawValue: 4)
let pidFd = CZ_pidfd_open(Int32(parentPid), 0)
guard pidFd > 0 else {
throw App.Errno(stage: "pidfd_open(\(parentPid))")
}
try Self.enterNS(
pidFd: pidFd,
nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS
)
let processID = fork()
guard processID != -1 else {
try? syncPipe.close()
try? ackPipe.close()
throw App.Errno(stage: "fork")
}
if processID == 0 { // child
// Wait for the grandparent to tell us that they acked our pid.
var pidAckBuffer = [UInt8](repeating: 0, count: App.ackPid.count)
let pidAckBytesRead = try pidAckBuffer.withUnsafeMutableBytes { buffer in
try ackPipe.read(into: buffer)
}
guard pidAckBytesRead > 0 else {
throw App.Failure(message: "read ack pipe")
}
let pidAckStr = String(decoding: pidAckBuffer[..<pidAckBytesRead], as: UTF8.self)
guard pidAckStr == App.ackPid else {
throw App.Failure(message: "received invalid acknowledgement string: \(pidAckStr)")
}
guard setsid() != -1 else {
throw App.Errno(stage: "setsid()")
}
if process.terminal {
let pty = try Console()
try pty.configureStdIO()
var masterFD = pty.master
try withUnsafeBytes(of: &masterFD) { bytes in
_ = try syncPipe.write(bytes)
}
// Wait for the grandparent to tell us that they acked our console.
var consoleAckBuffer = [UInt8](repeating: 0, count: App.ackConsole.count)
let consoleAckBytesRead = try consoleAckBuffer.withUnsafeMutableBytes { buffer in
try ackPipe.read(into: buffer)
}
guard consoleAckBytesRead > 0 else {
throw App.Failure(message: "read ack pipe")
}
let consoleAckStr = String(decoding: consoleAckBuffer[..<consoleAckBytesRead], as: UTF8.self)
guard consoleAckStr == App.ackConsole else {
throw App.Failure(message: "received invalid acknowledgement string: \(consoleAckStr)")
}
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
throw App.Errno(stage: "setctty(0)")
}
try pty.close()
}
// Apply O_CLOEXEC to all file descriptors except stdio.
// This ensures that all unwanted fds we may have accidentally
// inherited are marked close-on-exec so they stay out of the
// container.
try App.applyCloseExecOnFDs()
try App.setRLimits(rlimits: process.rlimits)
// Prepare capabilities (before user change)
let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities())
// Change stdio to be owned by the requested user.
try App.fixStdioPerms(user: process.user)
// Set uid, gid, and supplementary groups
try App.setPermissions(user: process.user)
// Finish capabilities (after user change)
try App.finishCapabilities(preparedCaps)
// Set no_new_privs if requested by the OCI spec.
try App.setNoNewPrivileges(process: process)
try App.exec(process: process, currentEnv: process.env)
} else { // parent process
// Send our child's pid to our parent before we exit.
var childPid = processID
try withUnsafeBytes(of: &childPid) { bytes in
_ = try syncPipe.write(bytes)
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ContainerizationOCI
import ContainerizationOS
import FoundationEssentials
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
struct ContainerMount {
private let mounts: [ContainerizationOCI.Mount]
private let rootfs: String
init(rootfs: String, mounts: [ContainerizationOCI.Mount]) {
self.rootfs = rootfs
self.mounts = mounts
}
func mountToRootfs() throws {
for m in self.mounts {
let osMount = m.toOSMount()
try osMount.mount(root: self.rootfs)
}
}
func configureConsole() throws {
let ptmx = rootfs + "/dev/ptmx"
guard remove(ptmx) == 0 else {
throw App.Errno(stage: "remove(ptmx)")
}
guard symlink("pts/ptmx", ptmx) == 0 else {
throw App.Errno(stage: "symlink(pts/ptmx)")
}
}
}
extension ContainerizationOCI.Mount {
func toOSMount() -> ContainerizationOS.Mount {
ContainerizationOS.Mount(
type: self.type,
source: self.source,
target: self.destination,
options: self.options
)
}
}
+502
View File
@@ -0,0 +1,502 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ArgumentParser
import Cgroup
import ContainerizationOCI
import ContainerizationOS
import FoundationEssentials
import LCShim
import SystemPackage
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
struct RunCommand: ParsableCommand {
static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run a container"
)
@Option(name: .long, help: "path to an OCI bundle")
var bundlePath: String
mutating func run() throws {
do {
let spec: ContainerizationOCI.Spec
do {
let bundle = try ContainerizationOCI.Bundle.load(path: URL(filePath: bundlePath))
spec = try bundle.loadConfig()
} catch {
throw App.Failure(message: "failed to load OCI bundle at \(bundlePath): \(error)")
}
try execInNamespace(spec: spec)
} catch {
App.writeError(error)
throw error
}
}
private func childRootSetup(
rootfs: ContainerizationOCI.Root,
mounts: [ContainerizationOCI.Mount]
) throws {
// setup rootfs
try prepareRoot(rootfs: rootfs.path)
try mountRootfs(rootfs: rootfs.path, mounts: mounts)
try setDevSymlinks(rootfs: rootfs.path)
try pivotRoot(rootfs: rootfs.path)
// Remount ro if requested.
if rootfs.readonly {
try self.remountRootfsReadOnly()
}
try reOpenDevNull()
}
/// Mask paths per OCI `linux.maskedPaths`. Files (and any non-directory)
/// get `/dev/null` bind-mounted on top; directories get an empty read-only
/// tmpfs. Missing paths are skipped silently matches runc's `maskPath`.
private func applyMaskedPaths(_ paths: [String]) throws {
for path in paths {
var st = stat()
if stat(path, &st) != 0 {
if errno == ENOENT {
continue
}
throw App.Errno(stage: "stat(\(path)) for mask")
}
if (st.st_mode & S_IFMT) == S_IFDIR {
// Match runc: mask directories with a read-only tmpfs. MS_RDONLY
// is what actually prevents writes into the masked dir; a
// `size=0k` option would be a no-op (the kernel treats tmpfs
// size=0 as "no limit", not an empty filesystem).
guard mount("tmpfs", path, "tmpfs", UInt(MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC), nil) == 0 else {
throw App.Errno(stage: "mount(tmpfs mask \(path))")
}
} else {
guard mount("/dev/null", path, "bind", UInt(MS_BIND), nil) == 0 else {
throw App.Errno(stage: "mount(bind /dev/null -> \(path))")
}
}
}
}
/// Make paths read-only per OCI `linux.readonlyPaths` by bind-mounting
/// each onto itself and remounting with `MS_RDONLY`. Missing paths are
/// skipped silently matches runc's `readonlyPath`. The statfs fallback
/// mirrors `remountRootfsReadOnly()` for filesystems whose existing flags
/// (e.g. nosuid, nodev) must be preserved on the remount.
private func applyReadonlyPaths(_ paths: [String]) throws {
for path in paths {
var st = stat()
if stat(path, &st) != 0 {
if errno == ENOENT {
continue
}
throw App.Errno(stage: "stat(\(path)) for readonly")
}
guard mount(path, path, "", UInt(MS_BIND | MS_REC), nil) == 0 else {
throw App.Errno(stage: "mount(bind \(path))")
}
var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY)
if mount("", path, "", flags, "") == 0 {
continue
}
var s = statfs()
guard statfs(path, &s) == 0 else {
throw App.Errno(stage: "statfs(\(path))")
}
flags |= UInt(s.f_flags)
guard mount("", path, "", flags, "") == 0 else {
throw App.Errno(stage: "mount remount-ro \(path)")
}
}
}
private func remountRootfsReadOnly() throws {
var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY)
let ret = mount("", "/", "", flags, "")
if ret == 0 {
return
}
var s = statfs()
guard statfs("/", &s) == 0 else {
throw App.Errno(stage: "statfs(/)")
}
flags |= UInt(s.f_flags)
guard mount("", "/", "", flags, "") == 0 else {
throw App.Errno(stage: "mount rootfs ro")
}
}
private func childSetup(
spec: ContainerizationOCI.Spec,
ackPipe: FileDescriptor,
syncPipe: FileDescriptor
) throws {
guard let process = spec.process else {
throw App.Failure(message: "no process configuration found in runtime spec")
}
guard let root = spec.root else {
throw App.Failure(message: "no root found in runtime spec")
}
// Wait for the grandparent to tell us that they acked our pid.
var pidAckBuffer = [UInt8](repeating: 0, count: App.ackPid.count)
let pidAckBytesRead = try pidAckBuffer.withUnsafeMutableBytes { buffer in
try ackPipe.read(into: buffer)
}
guard pidAckBytesRead > 0 else {
throw App.Failure(message: "read ack pipe")
}
let pidAckStr = String(decoding: pidAckBuffer[..<pidAckBytesRead], as: UTF8.self)
guard pidAckStr == App.ackPid else {
throw App.Failure(message: "received invalid acknowledgement string: \(pidAckStr)")
}
guard unshare(CLONE_NEWCGROUP) == 0 else {
throw App.Errno(stage: "unshare(cgroup)")
}
guard setsid() != -1 else {
throw App.Errno(stage: "setsid()")
}
try childRootSetup(rootfs: root, mounts: spec.mounts)
if process.terminal {
let pty = try Console()
try pty.configureStdIO()
var masterFD = pty.master
try withUnsafeBytes(of: &masterFD) { bytes in
_ = try syncPipe.write(bytes)
}
// Wait for the grandparent to tell us that they acked our console.
var consoleAckBuffer = [UInt8](repeating: 0, count: App.ackConsole.count)
let consoleAckBytesRead = try consoleAckBuffer.withUnsafeMutableBytes { buffer in
try ackPipe.read(into: buffer)
}
guard consoleAckBytesRead > 0 else {
throw App.Failure(message: "read ack pipe")
}
let consoleAckStr = String(decoding: consoleAckBuffer[..<consoleAckBytesRead], as: UTF8.self)
guard consoleAckStr == App.ackConsole else {
throw App.Failure(message: "received invalid acknowledgement string: \(consoleAckStr)")
}
guard ioctl(0, UInt(TIOCSCTTY), 0) != -1 else {
throw App.Errno(stage: "setctty(0)")
}
try mountConsole(path: pty.slavePath)
try pty.close()
}
if !spec.hostname.isEmpty {
let errCode = spec.hostname.withCString { ptr in
sethostname(ptr, spec.hostname.count)
}
guard errCode == 0 else {
throw App.Errno(stage: "sethostname()")
}
}
// Apply sysctls from the OCI spec.
if let sysctls = spec.linux?.sysctl {
for (key, value) in sysctls {
let path = "/proc/sys/" + key.replacingOccurrences(of: ".", with: "/")
let fd = open(path, O_WRONLY)
guard fd >= 0 else {
throw App.Errno(stage: "sysctl open(\(path))")
}
defer { close(fd) }
let bytes = Array(value.utf8)
let written = write(fd, bytes, bytes.count)
guard written == bytes.count else {
throw App.Errno(stage: "sysctl write(\(key)=\(value))")
}
}
}
// Apply OCI maskedPaths/readonlyPaths AFTER sysctls (writes to
// /proc/sys/* would otherwise fail once /proc/sys is remounted ro)
// and BEFORE the user/capability change (mount() requires
// CAP_SYS_ADMIN, which we still have here as root). Mask runs first
// so a path appearing in both lists is hidden, not just locked.
try self.applyMaskedPaths(spec.linux?.maskedPaths ?? [])
try self.applyReadonlyPaths(spec.linux?.readonlyPaths ?? [])
// Apply O_CLOEXEC to all file descriptors except stdio.
// This ensures that all unwanted fds we may have accidentally
// inherited are marked close-on-exec so they stay out of the
// container.
try App.applyCloseExecOnFDs()
try App.setRLimits(rlimits: process.rlimits)
// Prepare capabilities (before user change)
let preparedCaps = try App.prepareCapabilities(capabilities: process.capabilities ?? ContainerizationOCI.LinuxCapabilities())
// Change stdio to be owned by the requested user.
try App.fixStdioPerms(user: process.user)
// Set uid, gid, and supplementary groups.
try App.setPermissions(user: process.user)
// Finish capabilities (after user change)
try App.finishCapabilities(preparedCaps)
// Set no_new_privs if requested by the OCI spec.
try App.setNoNewPrivileges(process: process)
// Finally execve the container process.
try App.exec(process: process, currentEnv: process.env)
}
private func setupNamespaces(namespaces: [ContainerizationOCI.LinuxNamespace]?) throws -> Int32 {
var unshareFlags: Int32 = 0
// Map namespace types to their corresponding CLONE flags
let nsTypeToFlag: [ContainerizationOCI.LinuxNamespaceType: Int32] = [
.pid: CLONE_NEWPID,
.mount: CLONE_NEWNS,
.uts: CLONE_NEWUTS,
.ipc: CLONE_NEWIPC,
.user: CLONE_NEWUSER,
.cgroup: CLONE_NEWCGROUP,
]
guard let namespaces = namespaces else {
return CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS
}
for ns in namespaces {
guard let flag = nsTypeToFlag[ns.type] else {
continue
}
if ns.path.isEmpty {
unshareFlags |= flag
} else {
let fd = open(ns.path, O_RDONLY | O_CLOEXEC)
guard fd >= 0 else {
throw App.Errno(stage: "open(\(ns.path))")
}
defer { close(fd) }
guard setns(fd, flag) == 0 else {
throw App.Errno(stage: "setns(\(ns.path))")
}
}
}
return unshareFlags
}
private func execInNamespace(spec: ContainerizationOCI.Spec) throws {
let syncPipe = FileDescriptor(rawValue: 3)
let ackPipe = FileDescriptor(rawValue: 4)
let unshareFlags = try setupNamespaces(namespaces: spec.linux?.namespaces)
guard unshare(unshareFlags) == 0 else {
throw App.Errno(stage: "unshare(\(unshareFlags))")
}
let processID = fork()
guard processID != -1 else {
try? syncPipe.close()
try? ackPipe.close()
throw App.Errno(stage: "fork")
}
if processID == 0 { // child
try childSetup(spec: spec, ackPipe: ackPipe, syncPipe: syncPipe)
} else { // parent process
// Setup cgroup before child enters cgroup namespace
if let linux = spec.linux {
let cgroupPath = linux.cgroupsPath
if !cgroupPath.isEmpty {
let cgroupManager = try Cgroup2Manager.load(group: URL(filePath: cgroupPath))
if let resources = linux.resources {
try cgroupManager.applyResources(resources: resources)
}
try cgroupManager.addProcess(pid: processID)
}
}
// Send our child's pid before we exit.
var childPid = processID
try withUnsafeBytes(of: &childPid) { bytes in
_ = try syncPipe.write(bytes)
}
}
}
private func mountRootfs(rootfs: String, mounts: [ContainerizationOCI.Mount]) throws {
let containerMount = ContainerMount(rootfs: rootfs, mounts: mounts)
try containerMount.mountToRootfs()
try containerMount.configureConsole()
}
private func prepareRoot(rootfs: String) throws {
guard mount("", "/", "", UInt(MS_SLAVE | MS_REC), nil) == 0 else {
throw App.Errno(stage: "mount(slave|rec)")
}
guard mount(rootfs, rootfs, "bind", UInt(MS_BIND | MS_REC), nil) == 0 else {
throw App.Errno(stage: "mount(bind|rec)")
}
}
private func setDevSymlinks(rootfs: String) throws {
let links: [(src: String, dst: String)] = [
("/proc/self/fd", "/dev/fd"),
("/proc/self/fd/0", "/dev/stdin"),
("/proc/self/fd/1", "/dev/stdout"),
("/proc/self/fd/2", "/dev/stderr"),
("/dev/rtc0", "/dev/rtc"),
]
let rootfsURL = URL(fileURLWithPath: rootfs)
for (src, dst) in links {
let dest = rootfsURL.appendingPathComponent(dst)
guard symlink(src, dest.path) == 0 else {
if errno == EEXIST {
continue
}
throw App.Errno(stage: "symlink(\(src) -> \(dest.path))")
}
}
}
private func reOpenDevNull() throws {
let file = open("/dev/null", O_RDWR)
guard file != -1 else {
throw App.Errno(stage: "open(/dev/null)")
}
defer { close(file) }
var devNullStat = stat()
try withUnsafeMutablePointer(to: &devNullStat) { pointer in
guard fstat(file, pointer) == 0 else {
throw App.Errno(stage: "fstat(/dev/null)")
}
}
for fd: Int32 in 0...2 {
var fdStat = stat()
try withUnsafeMutablePointer(to: &fdStat) { pointer in
guard fstat(fd, pointer) == 0 else {
throw App.Errno(stage: "fstat(fd)")
}
}
if fdStat.st_rdev == devNullStat.st_rdev {
guard dup3(file, fd, 0) != -1 else {
throw App.Errno(stage: "dup3(null)")
}
}
}
}
/// Pivots the rootfs of the calling process in the namespace to the provided
/// rootfs in the argument.
///
/// The pivot_root(".", ".") and unmount old root approach is exactly the same
/// as runc's pivot root implementation in:
/// https://github.com/opencontainers/runc/blob/main/libcontainer/rootfs_linux.go
private func pivotRoot(rootfs: String) throws {
let oldRoot = open("/", O_RDONLY | O_DIRECTORY)
if oldRoot <= 0 {
throw App.Errno(stage: "open(oldroot)")
}
defer { close(oldRoot) }
let newRoot = open(rootfs, O_RDONLY | O_DIRECTORY)
if newRoot <= 0 {
throw App.Errno(stage: "open(newroot)")
}
defer { close(newRoot) }
// change cwd to the new root
guard fchdir(newRoot) == 0 else {
throw App.Errno(stage: "fchdir(newroot)")
}
guard CZ_pivot_root(toCString("."), toCString(".")) == 0 else {
throw App.Errno(stage: "pivot_root()")
}
// change cwd to the old root
guard fchdir(oldRoot) == 0 else {
throw App.Errno(stage: "fchdir(oldroot)")
}
// mount old root rslave so that unmount doesn't propagate back to outside
// the namespace
guard mount("", ".", "", UInt(MS_SLAVE | MS_REC), nil) == 0 else {
throw App.Errno(stage: "mount(., slave|rec)")
}
// unmount old root
guard umount2(".", Int32(MNT_DETACH)) == 0 else {
throw App.Errno(stage: "umount(.)")
}
// switch cwd to the new root
guard chdir("/") == 0 else {
throw App.Errno(stage: "chdir(/)")
}
}
private func toCString(_ str: String) -> UnsafeMutablePointer<CChar>? {
let cString = str.utf8CString
let cStringCopy = UnsafeMutableBufferPointer<CChar>.allocate(capacity: cString.count)
_ = cStringCopy.initialize(from: cString)
return UnsafeMutablePointer(cStringCopy.baseAddress)
}
private func mountConsole(path: String) throws {
let console = "/dev/console"
if access(console, F_OK) != 0 {
let fd = open(console, O_RDWR | O_CREAT, mode_t(UInt16(0o600)))
guard fd != -1 else {
throw App.Errno(stage: "open(/dev/console)")
}
close(fd)
}
guard mount(path, console, "bind", UInt(MS_BIND), nil) == 0 else {
throw App.Errno(stage: "mount(console)")
}
}
}
+285
View File
@@ -0,0 +1,285 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
/// NOTE: This binary implements a very small subset of the OCI runtime spec, mostly just
/// the process configurations. Mounts, masked paths, and read-only paths are enforced.
/// The `network` namespace is currently ignored and we always spawn a new pid and mount
/// namespace.
import ArgumentParser
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import FoundationEssentials
import LCShim
import Logging
import SystemPackage
#if canImport(Musl)
import Musl
#elseif canImport(Glibc)
import Glibc
#endif
@main
struct App: ParsableCommand {
static let ackPid = "AckPid"
static let ackConsole = "AckConsole"
static let configuration = CommandConfiguration(
commandName: "vmexec",
version: "0.1.0",
subcommands: [
ExecCommand.self,
RunCommand.self,
]
)
}
extension App {
/// Applies O_CLOEXEC to all file descriptors currently open for
/// the process except the stdio fd values
static func applyCloseExecOnFDs() throws {
let minFD = 2 // stdin, stdout, stderr should be preserved
let fdList = try FileManager.default.contentsOfDirectory(atPath: "/proc/self/fd")
for fdStr in fdList {
guard let fd = Int(fdStr) else {
continue
}
if fd <= minFD {
continue
}
_ = fcntl(Int32(fd), F_SETFD, FD_CLOEXEC)
}
}
static func exec(process: ContainerizationOCI.Process, currentEnv: [String]? = nil) throws {
guard !process.args.isEmpty else {
throw App.Errno(stage: "exec", info: "process args cannot be empty")
}
let executableArg = process.args[0]
let resolvedExecutable: URL
if executableArg.contains("/") {
if executableArg.hasPrefix("/") {
resolvedExecutable = URL(fileURLWithPath: executableArg)
} else {
resolvedExecutable = URL(fileURLWithPath: process.cwd).appendingPathComponent(executableArg).standardized
}
guard FileManager.default.fileExists(atPath: resolvedExecutable.path) else {
throw App.Failure(message: "failed to find target executable \(executableArg)")
}
} else {
let path = Path.findPath(currentEnv) ?? Path.getCurrentPath()
guard let found = Path.lookPath(executableArg, path: path) else {
throw App.Failure(message: "failed to find target executable \(executableArg)")
}
resolvedExecutable = found
}
let executable = strdup(resolvedExecutable.path)
var argv = process.args.map { strdup($0) }
argv += [nil]
let env = process.env.map { strdup($0) } + [nil]
let cwd = process.cwd
// Create the working directory if it doesn't exist, this seems like the expected
// OCI runtime spec behavior.
if !FileManager.default.fileExists(atPath: cwd) {
try FileManager.default.createDirectory(
atPath: cwd,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o755]
)
}
guard chdir(cwd) == 0 else {
throw App.Errno(stage: "chdir(cwd)", info: "failed to change directory to '\(cwd)'")
}
guard execvpe(executable, argv, env) != -1 else {
throw App.Errno(stage: "execvpe(\(String(describing: executable)))", info: "failed to exec [\(process.args.joined(separator: " "))]")
}
fatalError("execvpe failed")
}
static func setPermissions(user: ContainerizationOCI.User) throws {
if user.additionalGids.count > 0 {
guard setgroups(user.additionalGids.count, user.additionalGids) == 0 else {
throw App.Errno(stage: "setgroups()")
}
}
guard setgid(user.gid) == 0 else {
throw App.Errno(stage: "setgid()")
}
// NOTE: setuid has to be done last because once the uid has been
// changed, then the process will lose privilege to set the group
// and supplementary groups
guard setuid(user.uid) == 0 else {
throw App.Errno(stage: "setuid()")
}
}
static func fixStdioPerms(user: ContainerizationOCI.User) throws {
for i in 0...2 {
var fdStat = stat()
try withUnsafeMutablePointer(to: &fdStat) { pointer in
guard fstat(Int32(i), pointer) == 0 else {
throw App.Errno(stage: "fstat(fd)")
}
}
let desired = uid_t(user.uid)
if fdStat.st_uid != desired {
guard fchown(Int32(i), desired, fdStat.st_gid) != -1 else {
throw App.Errno(stage: "fchown(\(i))")
}
}
}
}
static func setRLimits(rlimits: [ContainerizationOCI.POSIXRlimit]) throws {
for rl in rlimits {
let resource: Int32
switch rl.type {
case "RLIMIT_AS":
resource = CZ_RLIMIT_AS
case "RLIMIT_CORE":
resource = CZ_RLIMIT_CORE
case "RLIMIT_CPU":
resource = CZ_RLIMIT_CPU
case "RLIMIT_DATA":
resource = CZ_RLIMIT_DATA
case "RLIMIT_FSIZE":
resource = CZ_RLIMIT_FSIZE
case "RLIMIT_LOCKS":
resource = CZ_RLIMIT_LOCKS
case "RLIMIT_MEMLOCK":
resource = CZ_RLIMIT_MEMLOCK
case "RLIMIT_MSGQUEUE":
resource = CZ_RLIMIT_MSGQUEUE
case "RLIMIT_NICE":
resource = CZ_RLIMIT_NICE
case "RLIMIT_NOFILE":
resource = CZ_RLIMIT_NOFILE
case "RLIMIT_NPROC":
resource = CZ_RLIMIT_NPROC
case "RLIMIT_RSS":
resource = CZ_RLIMIT_RSS
case "RLIMIT_RTPRIO":
resource = CZ_RLIMIT_RTPRIO
case "RLIMIT_RTTIME":
resource = CZ_RLIMIT_RTTIME
case "RLIMIT_SIGPENDING":
resource = CZ_RLIMIT_SIGPENDING
case "RLIMIT_STACK":
resource = CZ_RLIMIT_STACK
default:
errno = EINVAL
throw App.Errno(stage: "rlimit key unknown")
}
guard CZ_setrlimit(resource, rl.soft, rl.hard) == 0 else {
throw App.Errno(stage: "setrlimit()")
}
}
}
static func prepareCapabilities(capabilities: ContainerizationOCI.LinuxCapabilities) throws -> ContainerizationOS.LinuxCapabilities? {
// Create capabilities instance from OCI config
var caps = ContainerizationOS.LinuxCapabilities()
caps.set(which: [.effective], caps: (capabilities.effective ?? []).compactMap { try? CapabilityName(rawValue: $0) })
caps.set(which: [.permitted], caps: (capabilities.permitted ?? []).compactMap { try? CapabilityName(rawValue: $0) })
caps.set(which: [.inheritable], caps: (capabilities.inheritable ?? []).compactMap { try? CapabilityName(rawValue: $0) })
caps.set(which: [.bounding], caps: (capabilities.bounding ?? []).compactMap { try? CapabilityName(rawValue: $0) })
caps.set(which: [.ambient], caps: (capabilities.ambient ?? []).compactMap { try? CapabilityName(rawValue: $0) })
// Apply bounding set BEFORE user change (drop capabilities early)
do {
try caps.apply(kind: .bounds)
} catch {
throw App.Failure(message: "failed to apply bounding set capabilities: \(error)")
}
// Set keep caps to preserve capabilities across setuid()
do {
try LinuxCapabilities.setKeepCaps()
} catch {
throw App.Failure(message: "failed to set keep caps: \(error)")
}
return caps
}
static func finishCapabilities(_ caps: ContainerizationOS.LinuxCapabilities?) throws {
guard let caps = caps else { return }
do {
try LinuxCapabilities.clearKeepCaps()
} catch {
throw App.Failure(message: "failed to clear keep caps: \(error)")
}
do {
try caps.apply(kind: [.caps])
} catch {
throw App.Failure(message: "failed to apply final capabilities: \(error)")
}
try? caps.apply(kind: [.ambs])
}
static func setNoNewPrivileges(process: ContainerizationOCI.Process) throws {
guard process.noNewPrivileges else { return }
guard CZ_prctl_set_no_new_privs() == 0 else {
throw App.Errno(stage: "prctl(PR_SET_NO_NEW_PRIVS)")
}
}
static func Errno(stage: String, info: String = "") -> ContainerizationError {
let posix = POSIXError(.init(rawValue: errno)!, userInfo: ["stage": stage])
return ContainerizationError(.internalError, message: "\(info) \(String(describing: posix))")
}
static func Failure(message: String) -> ContainerizationError {
ContainerizationError(
.internalError,
message: message
)
}
static func writeError(_ error: Error) {
let errorPipe = FileDescriptor(rawValue: 5)
let errorMessage: String
if let czError = error as? ContainerizationError {
errorMessage = czError.description
} else {
errorMessage = String(describing: error)
}
let bytes = Array(errorMessage.utf8)
_ = try? bytes.withUnsafeBytes { buffer in
try errorPipe.write(buffer)
}
try? errorPipe.close()
}
}
+118
View File
@@ -0,0 +1,118 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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 ArgumentParser
import CVersion
import ContainerizationOS
import Foundation
import Logging
import VminitdCore
@main
struct Application: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "vminitd",
abstract: "Virtual machine init daemon",
version: "0.1.0",
subcommands: [
AgentCommand.self,
InitCommand.self,
PauseCommand.self,
],
defaultSubcommand: AgentCommand.self
)
static func main() async throws {
setVersionMetadata(Self.versionMetadata())
// Busybox-style: if invoked as .cz-init, run init mode directly.
let invoked = CommandLine.arguments.first?.split(separator: "/").last.map(String.init) ?? ""
if invoked == ".cz-init" {
let args = Array(CommandLine.arguments.dropFirst())
var command = try InitCommand.parse(args)
try command.run()
return
}
// Swift has issues spawning threads if /proc isn't mounted,
// so we do this synchronously before any async code runs.
try mountProc()
// When running as PID 1 with a Musl-static build, Swift's runtime
// captures argc/argv as empty. Recover argv from /proc/self/cmdline.
var command = try parseAsRoot(Self.procSelfArgv())
if let asyncCommand = command as? AsyncParsableCommand {
nonisolated(unsafe) var unsafeCommand = asyncCommand
try await unsafeCommand.run()
} else {
try command.run()
}
}
private static func versionMetadata() -> Logger.Metadata {
let gitCommit = String(cString: CZ_get_git_commit())
let gitTag = String(cString: CZ_get_git_tag())
let buildTime = String(cString: CZ_get_build_time())
var metadata: Logger.Metadata = ["commit": "\(gitCommit)", "built": "\(buildTime)"]
if !gitTag.isEmpty {
metadata["tag"] = "\(gitTag)"
}
return metadata
}
private static func mountProc() throws {
if isProcMounted() {
return
}
let mnt = ContainerizationOS.Mount(
type: "proc",
source: "proc",
target: "/proc",
options: []
)
try mnt.mount(createWithPerms: 0o755)
}
// /proc/self/cmdline holds argv as NUL-separated bytes. Read it after
// mountProc(). Returns argv minus argv[0], suitable for parseAsRoot(_:).
private static func procSelfArgv() -> [String] {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: "/proc/self/cmdline")) else {
return []
}
let parts = data.split(separator: 0, omittingEmptySubsequences: true)
.map { String(decoding: $0, as: UTF8.self) }
return Array(parts.dropFirst())
}
private static func isProcMounted() -> Bool {
guard let data = try? String(contentsOfFile: "/proc/mounts", encoding: .utf8) else {
return false
}
for line in data.split(separator: "\n") {
let fields = line.split(separator: " ")
if fields.count >= 2 {
let mountPoint = String(fields[1])
if mountPoint == "/proc" {
return true
}
}
}
return false
}
}