Files
apple--containerization/vminitd/Sources/vmexec/RunCommand.swift
T
wehub-resource-sync 680845cb1c
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

503 lines
18 KiB
Swift

//===----------------------------------------------------------------------===//
// 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)")
}
}
}