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