Files
wehub-resource-sync 680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

1532 lines
64 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 ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import Synchronization
import SystemPackage
import struct ContainerizationOS.Terminal
/// `LinuxContainer` is an easy to use type for launching and managing the
/// full lifecycle of a Linux container ran inside of a virtual machine.
public final class LinuxContainer: Container, Sendable {
public static let maxIDLength = 64
/// The identifier of the container.
public let id: String
/// Rootfs for the container.
///
/// Note: The `destination` field of this mount is ignored as mounting is handled internally.
public let rootfs: Mount
/// Optional writable layer for the container. When provided, the rootfs
/// is mounted as the lower layer of an overlayfs, with this as the upper layer.
/// All writes will go to this layer instead of the rootfs.
///
/// Note: The `destination` field of this mount is ignored as mounting is handled internally.
public let writableLayer: Mount?
/// Configuration for the container.
public let config: Configuration
/// The configuration for the LinuxContainer.
public struct Configuration: Sendable {
/// Configuration for the init process of the container.
public var process = LinuxProcessConfiguration()
/// The amount of cpus for the container.
public var cpus: Int = 4
/// The memory in bytes to give to the container.
public var memoryInBytes: UInt64 = 1024.mib()
/// The hostname for the container.
public var hostname: String?
/// The system control options for the container.
public var sysctl: [String: String] = [:]
/// The network interfaces for the container.
public var interfaces: [any Interface] = []
/// The Unix domain socket relays to setup for the container.
public var sockets: [UnixSocketConfiguration] = []
/// The mounts for the container.
public var mounts: [Mount] = LinuxContainer.defaultMounts()
/// Paths inside the container that vmexec hides from the workload.
/// Defaults to the OCI standard set (``LinuxContainer/defaultMaskedPaths()``),
/// matching the restricted capability baseline. Set to `[]` to opt out,
/// or append to extend it.
public var maskedPaths: [String] = LinuxContainer.defaultMaskedPaths()
/// Paths inside the container that vmexec marks read-only.
/// Defaults to the OCI standard set (``LinuxContainer/defaultReadonlyPaths()``),
/// matching the restricted capability baseline. Set to `[]` to opt out,
/// or append to extend it.
public var readonlyPaths: [String] = LinuxContainer.defaultReadonlyPaths()
/// The DNS configuration for the container.
public var dns: DNS?
/// The hosts to add to /etc/hosts for the container.
public var hosts: Hosts?
/// Enable nested virtualization support.
public var virtualization: Bool = false
/// Optional destination for serial boot logs.
public var bootLog: BootLog?
/// EXPERIMENTAL: Path in the root filesystem for the virtual
/// machine where the OCI runtime used to spawn the container lives.
public var ociRuntimePath: String?
/// Run the container with a minimal init process that handles signal
/// forwarding and zombie reaping.
public var useInit: Bool = false
/// Additional CPU cores to allocate for the virtual machine on top
/// of the container's configured `cpus` value.
public var cpuOverhead: Int = 1
/// Additional memory in bytes to allocate for the virtual machine
/// on top of the container's configured `memoryInBytes` value.
/// The total is aligned to a 1 MiB boundary.
public var memoryOverhead: UInt64 = 128.mib()
public init() {}
public init(
process: LinuxProcessConfiguration,
cpus: Int = 4,
memoryInBytes: UInt64 = 1024.mib(),
hostname: String? = nil,
sysctl: [String: String] = [:],
interfaces: [any Interface] = [],
sockets: [UnixSocketConfiguration] = [],
mounts: [Mount] = LinuxContainer.defaultMounts(),
maskedPaths: [String] = LinuxContainer.defaultMaskedPaths(),
readonlyPaths: [String] = LinuxContainer.defaultReadonlyPaths(),
dns: DNS? = nil,
hosts: Hosts? = nil,
virtualization: Bool = false,
bootLog: BootLog? = nil,
ociRuntimePath: String? = nil,
useInit: Bool = false,
cpuOverhead: Int = 1,
memoryOverhead: UInt64 = 128.mib()
) {
self.process = process
self.cpus = cpus
self.memoryInBytes = memoryInBytes
self.hostname = hostname
self.sysctl = sysctl
self.interfaces = interfaces
self.sockets = sockets
self.mounts = mounts
self.maskedPaths = maskedPaths
self.readonlyPaths = readonlyPaths
self.dns = dns
self.hosts = hosts
self.virtualization = virtualization
self.bootLog = bootLog
self.ociRuntimePath = ociRuntimePath
self.useInit = useInit
self.cpuOverhead = cpuOverhead
self.memoryOverhead = memoryOverhead
}
}
private let state: AsyncMutex<State>
// Ports to be allocated from for stdio and for
// unix socket relays that are sharing a guest
// uds to the host.
private let hostVsockPorts: Atomic<UInt32>
// Ports we request the guest to allocate for unix socket relays from
// the host.
private let guestVsockPorts: Atomic<UInt32>
// Queue for copy IO.
private let copyQueue = DispatchQueue(label: "com.apple.containerization.copy")
private enum State: Sendable {
/// The container class has been created but no live resources are running.
case initialized
/// The container's virtual machine has been setup and the runtime environment has been configured.
case created(CreatedState)
/// The initial process of the container has started and is running.
case started(StartedState)
/// The container has run and fully stopped.
case stopped
/// An error occurred during the lifetime of this class.
case errored(Swift.Error)
/// The container is paused.
case paused(PausedState)
struct CreatedState: Sendable {
let vm: any VirtualMachineInstance
let relayManager: UnixSocketRelayManager
var fileMountContext: FileMountContext
}
struct StartedState: Sendable {
let vm: any VirtualMachineInstance
let process: LinuxProcess
let relayManager: UnixSocketRelayManager
var vendedProcesses: [String: LinuxProcess]
let fileMountContext: FileMountContext
init(_ state: CreatedState, process: LinuxProcess) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = process
self.vendedProcesses = [:]
self.fileMountContext = state.fileMountContext
}
init(_ state: PausedState) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.fileMountContext = state.fileMountContext
}
}
struct PausedState: Sendable {
let vm: any VirtualMachineInstance
let relayManager: UnixSocketRelayManager
let process: LinuxProcess
var vendedProcesses: [String: LinuxProcess]
let fileMountContext: FileMountContext
init(_ state: StartedState) {
self.vm = state.vm
self.relayManager = state.relayManager
self.process = state.process
self.vendedProcesses = state.vendedProcesses
self.fileMountContext = state.fileMountContext
}
}
func createdState(_ operation: String) throws -> CreatedState {
switch self {
case .created(let state):
return state
case .errored(let err):
throw err
default:
throw ContainerizationError(
.invalidState,
message: "failed to \(operation): container must be created"
)
}
}
func startedState(_ operation: String) throws -> StartedState {
switch self {
case .started(let state):
return state
case .errored(let err):
throw err
default:
throw ContainerizationError(
.invalidState,
message: "failed to \(operation): container must be running"
)
}
}
func pausedState(_ operation: String) throws -> PausedState {
switch self {
case .paused(let state):
return state
case .errored(let err):
throw err
default:
throw ContainerizationError(
.invalidState,
message: "failed to \(operation): container must be paused"
)
}
}
mutating func validateForCreate() throws {
switch self {
case .initialized, .stopped:
break
case .errored(let err):
throw err
default:
throw ContainerizationError(
.invalidState,
message: "container must be in initialized or stopped state to create"
)
}
}
mutating func setErrored(error: Swift.Error) {
self = .errored(error)
}
func vm(_ operation: String) throws -> any VirtualMachineInstance {
switch self {
case .created(let state):
return state.vm
case .started(let state):
return state.vm
case .paused(let state):
return state.vm
case .errored(let err):
throw err
default:
throw ContainerizationError(
.invalidState,
message: "failed to \(operation): container must be created, running, or paused"
)
}
}
}
private let vmm: VirtualMachineManager
private let logger: Logger?
/// Create a new `LinuxContainer`.
///
/// - Parameters:
/// - id: The identifier for the container.
/// - rootfs: The root filesystem mount containing the container image contents.
/// The `destination` field is ignored as mounting is handled internally.
/// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with
/// rootfs as the lower layer and this as the upper layer. Must be a block device.
/// The `destination` field is ignored as mounting is handled internally.
/// - vmm: The virtual machine manager that will handle launching the VM for the container.
/// - logger: Optional logger for container operations.
/// - configuration: A closure that configures the container by modifying the Configuration instance.
public convenience init(
_ id: String,
rootfs: Mount,
writableLayer: Mount? = nil,
vmm: VirtualMachineManager,
logger: Logger? = nil,
configuration: (inout Configuration) throws -> Void
) throws {
var config = Configuration()
try configuration(&config)
try self.init(
id,
rootfs: rootfs,
writableLayer: writableLayer,
vmm: vmm,
configuration: config,
logger: logger
)
}
/// Create a new `LinuxContainer`.
///
/// - Parameters:
/// - id: The identifier for the container.
/// - rootfs: The root filesystem mount containing the container image contents.
/// The `destination` field is ignored as mounting is handled internally.
/// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with
/// rootfs as the lower layer and this as the upper layer. Must be a block device.
/// The `destination` field is ignored as mounting is handled internally.
/// - vmm: The virtual machine manager that will handle launching the VM for the container.
/// - configuration: The container configuration specifying process, resources, networking, and other settings.
/// - logger: Optional logger for container operations.
public init(
_ id: String,
rootfs: Mount,
writableLayer: Mount? = nil,
vmm: VirtualMachineManager,
configuration: LinuxContainer.Configuration,
logger: Logger? = nil
) throws {
guard id.count <= Self.maxIDLength else {
throw ContainerizationError(
.invalidArgument,
message: "container id length \(id.count) exceeds maximum of \(Self.maxIDLength) characters"
)
}
if let writableLayer {
guard writableLayer.isBlock else {
throw ContainerizationError(
.invalidArgument,
message: "writableLayer must be a block device"
)
}
}
self.id = id
self.vmm = vmm
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
self.logger = logger
self.config = configuration
self.state = AsyncMutex(.initialized)
self.rootfs = rootfs
self.writableLayer = writableLayer
}
private static func createDefaultRuntimeSpec(_ id: String) -> Spec {
.init(
process: .init(),
hostname: id,
root: .init(
path: Self.guestRootfsPath(id),
readonly: false
),
linux: .init(
resources: .init(),
cgroupsPath: "/container/\(id)"
)
)
}
private func generateRuntimeSpec() -> Spec {
var spec = Self.createDefaultRuntimeSpec(id)
// Process toggles.
spec.process = config.process.toOCI()
// Wrap with init process if requested.
if config.useInit {
let originalArgs = spec.process?.args ?? []
spec.process?.args = ["/.cz-init", "--"] + originalArgs
}
// General toggles.
if let hostname = config.hostname {
spec.hostname = hostname
}
// Linux toggles.
spec.linux?.sysctl = config.sysctl
spec.linux?.maskedPaths = config.maskedPaths
spec.linux?.readonlyPaths = config.readonlyPaths
// If the rootfs was requested as read-only, set it in the OCI spec.
// We let the OCI runtime remount as ro, instead of doing it originally.
// However, if we have a writable layer, the overlay allows writes so we don't mark it read-only.
spec.root?.readonly = self.rootfs.options.contains("ro") && self.writableLayer == nil
// Resource limits.
// CPU: quota/period model where period is 100ms (100,000µs) and quota is cpus * period
// Memory: limit in bytes
spec.linux?.resources = LinuxResources(
memory: LinuxMemory(
limit: Int64(config.memoryInBytes)
),
cpu: LinuxCPU(
quota: Int64(config.cpus * 100_000),
period: 100_000
)
)
spec.linux?.namespaces = [
LinuxNamespace(type: .cgroup),
LinuxNamespace(type: .ipc),
LinuxNamespace(type: .mount),
LinuxNamespace(type: .pid),
LinuxNamespace(type: .uts),
]
return spec
}
/// The default set of mounts for a LinuxContainer.
public static func defaultMounts() -> [Mount] {
let defaultOptions = ["nosuid", "noexec", "nodev"]
return [
.any(type: "proc", source: "proc", destination: "/proc"),
.any(type: "sysfs", source: "sysfs", destination: "/sys", options: defaultOptions),
.any(type: "devtmpfs", source: "none", destination: "/dev", options: ["nosuid", "mode=755"]),
.any(type: "mqueue", source: "mqueue", destination: "/dev/mqueue", options: defaultOptions),
.any(type: "tmpfs", source: "tmpfs", destination: "/dev/shm", options: defaultOptions + ["mode=1777", "size=65536k"]),
.any(type: "cgroup2", source: "none", destination: "/sys/fs/cgroup", options: defaultOptions),
.any(type: "devpts", source: "devpts", destination: "/dev/pts", options: ["nosuid", "noexec", "newinstance", "gid=5", "mode=0620", "ptmxmode=0666"]),
]
}
/// The default set of paths to mask inside a container, matching the OCI
/// runtime spec defaults that runc and other production runtimes apply.
/// Each path is hidden from the workload (replaced by `/dev/null` for files
/// or an empty tmpfs for directories) by `vmexec` after `pivot_root`.
///
/// Applied by default (see ``Configuration/maskedPaths``); set
/// `config.maskedPaths = []` to opt out, or append to extend the set.
public static func defaultMaskedPaths() -> [String] {
[
"/proc/asound",
"/proc/acpi",
"/proc/kcore",
"/proc/keys",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/proc/scsi",
"/sys/firmware",
"/sys/devices/virtual/powercap",
]
}
/// The default set of paths to mark read-only inside a container, matching
/// the OCI runtime spec defaults that runc and other production runtimes apply.
///
/// Applied by default (see ``Configuration/readonlyPaths``); set
/// `config.readonlyPaths = []` to opt out, or append to extend the set.
public static func defaultReadonlyPaths() -> [String] {
[
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
]
}
/// A more traditional default set of mounts that OCI runtimes typically employ.
public static func defaultOCIMounts() -> [Mount] {
let defaultOptions = ["nosuid", "noexec", "nodev"]
return [
.any(type: "proc", source: "proc", destination: "/proc"),
.any(type: "tmpfs", source: "tmpfs", destination: "/dev", options: ["nosuid", "mode=755", "size=65536k"]),
.any(type: "devpts", source: "devpts", destination: "/dev/pts", options: ["nosuid", "noexec", "newinstance", "gid=5", "mode=0620", "ptmxmode=0666"]),
.any(type: "sysfs", source: "sysfs", destination: "/sys", options: defaultOptions),
.any(type: "mqueue", source: "mqueue", destination: "/dev/mqueue", options: defaultOptions),
.any(type: "tmpfs", source: "tmpfs", destination: "/dev/shm", options: defaultOptions + ["mode=1777", "size=65536k"]),
.any(type: "cgroup2", source: "none", destination: "/sys/fs/cgroup", options: defaultOptions),
]
}
private static func guestRootfsPath(_ id: String) -> String {
"/run/container/\(id)/rootfs"
}
private static func guestSocketStagingPath(_ socketID: String) -> String {
"/run/sockets/\(socketID).sock"
}
}
extension LinuxContainer {
package var root: String {
Self.guestRootfsPath(id)
}
/// Number of CPU cores allocated.
public var cpus: Int {
config.cpus
}
/// Amount of memory in bytes allocated for the container.
/// This will be aligned to a 1MB boundary if it isn't already.
public var memoryInBytes: UInt64 {
config.memoryInBytes
}
/// Network interfaces of the container.
public var interfaces: [any Interface] {
config.interfaces
}
private func mountRootfs(
attachments: [AttachedFilesystem],
rootfsPath: String,
agent: VirtualMachineAgent
) async throws {
guard let rootfsAttachment = attachments.first else {
throw ContainerizationError(.notFound, message: "rootfs mount not found")
}
if self.writableLayer != nil {
// Set up overlayfs with image as lower layer and writable layer as upper.
guard attachments.count >= 2 else {
throw ContainerizationError(
.notFound,
message: "writable layer mount not found"
)
}
let writableAttachment = attachments[1]
let lowerPath = "/run/container/\(self.id)/lower"
let upperMountPath = "/run/container/\(self.id)/upper"
let upperPath = "/run/container/\(self.id)/upper/diff"
let workPath = "/run/container/\(self.id)/upper/work"
// Mount the image (lower layer) as read-only.
var lowerMount = rootfsAttachment.to
lowerMount.destination = lowerPath
if !lowerMount.options.contains("ro") {
lowerMount.options.append("ro")
}
try await agent.mount(lowerMount)
// Mount the writable layer.
var upperMount = writableAttachment.to
upperMount.destination = upperMountPath
try await agent.mount(upperMount)
// Create the upper and work directories inside the writable layer.
try await agent.mkdir(path: upperPath, all: true, perms: 0o755)
try await agent.mkdir(path: workPath, all: true, perms: 0o755)
// Mount the overlay.
let overlayMount = ContainerizationOCI.Mount(
type: "overlay",
source: "overlay",
destination: rootfsPath,
options: [
"lowerdir=\(lowerPath)",
"upperdir=\(upperPath)",
"workdir=\(workPath)",
]
)
try await agent.mount(overlayMount)
} else {
// No writable layer. Mount rootfs directly.
var rootfs = rootfsAttachment.to
rootfs.destination = rootfsPath
try await agent.mount(rootfs)
}
}
/// Create and start the underlying container's virtual machine
/// and set up the runtime environment. The container's init process
/// is NOT running afterwards.
public func create() async throws {
try await self.state.withLock { state in
try state.validateForCreate()
// This is a bit of an annoyance, but because the type we use for the rootfs is simply
// the same Mount type we use for non-rootfs mounts, it's possible someone passed 'ro'
// in the options (which should be perfectly valid). However, the problem is when we go to
// setup /etc/hosts and /etc/resolv.conf, as we'd get EROFS if they did supply 'ro'.
// To remedy this, remove any "ro" options before passing to VZ. Having the OCI runtime
// remount "ro" (which is what we do later in the guest) is truthfully the right thing,
// but this bit here is just a tad awkward.
var modifiedRootfs = self.rootfs
modifiedRootfs.options.removeAll(where: { $0 == "ro" })
let vmMemory = self.memoryInBytes + self.config.memoryOverhead
let vmCpus = self.cpus + self.config.cpuOverhead
// Prepare file mounts. This transforms single-file mounts into directory shares.
let fileMountContext = try FileMountContext.prepare(mounts: self.config.mounts)
// This is dumb, but alas.
let fileMountContextHolder = Mutex<FileMountContext>(fileMountContext)
// Build the list of mounts to attach to the VM.
var containerMounts = [modifiedRootfs] + fileMountContext.transformedMounts
if let writableLayer = self.writableLayer {
containerMounts.insert(writableLayer, at: 1)
}
let vmConfig = VMConfiguration(
cpus: vmCpus,
memoryInBytes: vmMemory,
interfaces: self.interfaces,
mountsByID: [self.id: containerMounts],
bootLog: self.config.bootLog,
nestedVirtualization: self.config.virtualization
)
let creationConfig = StandardVMConfig(configuration: vmConfig)
let vm = try await self.vmm.create(config: creationConfig)
let relayManager = UnixSocketRelayManager(vm: vm, log: self.logger)
try await vm.start()
do {
let mountsForAgent = containerMounts
try await vm.withAgent { agent in
try await agent.standardSetup()
// Mount the unified virtiofs share at /run/virtiofs only
// when at least one of the container's mounts is virtiofs
// — the bind-mount transform below derives its sources
// from /run/virtiofs/{tag}, so the unified share is only
// load-bearing when there are virtiofs mounts. The macOS
// VZ backend always exposes the virtiofs device (even
// with zero shares), but the cloud-hypervisor backend
// only spawns virtiofsd when shares exist; mounting an
// unbacked tag fails with EINVAL.
let hasVirtiofsMount = mountsForAgent.contains { mount in
if case .virtiofs = mount.runtimeOptions { return true }
return false
}
if hasVirtiofsMount {
// VZ exposes ONE virtio-fs device with tag "virtiofs"
// and multiple sources as subdirs (VZMultipleDirectoryShare).
// The CH backend exposes one device per source-hash
// tag instead, so the guest must mount each tag
// separately at /run/virtiofs/<tag>. The bind-mount
// transform below uses /run/virtiofs/<tag> in both
// cases, so this branch is only about how /run/virtiofs
// gets populated.
if vm.virtiofsLayout == .perTag {
try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755)
let virtiofsAttachments = (vm.mounts[self.id] ?? []).filter { $0.type == "virtiofs" }
let uniqueTags = Set(virtiofsAttachments.map(\.source))
for tag in uniqueTags {
let dest = "/run/virtiofs/\(tag)"
try await agent.mkdir(path: dest, all: true, perms: 0o755)
try await agent.mount(
ContainerizationOCI.Mount(
type: "virtiofs",
source: tag,
destination: dest,
options: []
))
}
} else {
try await agent.mount(
ContainerizationOCI.Mount(
type: "virtiofs",
source: "virtiofs",
destination: "/run/virtiofs",
options: []
))
}
}
guard let attachments = vm.mounts[self.id] else {
throw ContainerizationError(.notFound, message: "rootfs mount not found")
}
let rootfsPath = Self.guestRootfsPath(self.id)
try await self.mountRootfs(attachments: attachments, rootfsPath: rootfsPath, agent: agent)
// Mount file mount holding directories under /run.
if fileMountContext.hasFileMounts {
let containerMounts = vm.mounts[self.id] ?? []
var ctx = fileMountContextHolder.withLock { $0 }
try await ctx.mountHoldingDirectories(
vmMounts: containerMounts,
agent: agent
)
fileMountContextHolder.withLock { $0 = ctx }
}
// Start up our friendly unix socket relays.
for socket in self.config.sockets {
try await self.relayUnixSocket(
socket: socket,
relayManager: relayManager,
agent: agent
)
}
// For every interface asked for:
// 1. Add the address requested
// 2. Online the adapter
// 3. For the first interface, add the default route
var defaultRouteSet = false
for (index, i) in self.interfaces.enumerated() {
let name = "eth\(index)"
try await agent.setupInterface(
i,
name: name,
setDefaultRoute: !defaultRouteSet,
logger: self.logger
)
defaultRouteSet = true
}
// Setup /etc/resolv.conf and /etc/hosts if asked for.
if let dns = self.config.dns {
try await agent.configureDNS(config: dns, location: rootfsPath)
}
if let hosts = self.config.hosts {
try await agent.configureHosts(config: hosts, location: rootfsPath)
}
}
state = .created(.init(vm: vm, relayManager: relayManager, fileMountContext: fileMountContextHolder.withLock { $0 }))
} catch {
try? await relayManager.stopAll()
try? await vm.stop()
state.setErrored(error: error)
throw error
}
}
}
/// Start the container's initial process.
public func start() async throws {
try await self.state.withLock { state in
let createdState = try state.createdState("start")
let agent = try await createdState.vm.dialAgent()
do {
var spec = self.generateRuntimeSpec()
// We don't need the rootfs (or writable layer), nor do OCI runtimes want it included.
// Also filter out file mount holding directories. We'll mount those separately under /run.
// Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag}
let containerMounts = createdState.vm.mounts[self.id] ?? []
let holdingTags = createdState.fileMountContext.holdingDirectoryTags
// Drop rootfs, and writable layer if present.
let mountsToSkip = self.writableLayer != nil ? 2 : 1
var mounts: [ContainerizationOCI.Mount] =
containerMounts.dropFirst(mountsToSkip)
.filter { !holdingTags.contains($0.source) }
.map { attached -> ContainerizationOCI.Mount in
if attached.type == "virtiofs" {
// Transform to bind mount from holding directory
return ContainerizationOCI.Mount(
type: "none",
source: "/run/virtiofs/\(attached.source)",
destination: attached.destination,
options: ["bind"] + attached.options
)
}
return attached.to
}
+ createdState.fileMountContext.ociBindMounts()
// When useInit is enabled, bind mount vminitd from the VM's filesystem
// into the container so it can be executed.
if self.config.useInit {
mounts.append(
ContainerizationOCI.Mount(
type: "bind",
source: "/sbin/vminitd",
destination: "/.cz-init",
options: ["bind", "ro"]
))
}
// Bind mount staged sockets into the container. Sockets relayed
// .into the container are created in a staging directory outside
// the rootfs to avoid symlink traversal and mount shadowing.
for socket in self.config.sockets where socket.direction == .into {
mounts.append(
ContainerizationOCI.Mount(
type: "bind",
source: Self.guestSocketStagingPath(socket.id),
destination: socket.destination.path,
options: ["bind"]
))
}
spec.mounts = cleanAndSortMounts(mounts)
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: self.config.process.stdin,
stdout: self.config.process.stdout,
stderr: self.config.process.stderr
)
let process = LinuxProcess(
self.id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: createdState.vm,
logger: self.logger
)
try await process.start()
state = .started(.init(createdState, process: process))
} catch {
try? await agent.close()
try? await createdState.vm.stop()
state.setErrored(error: error)
throw error
}
}
}
/// Stop the container from executing. This MUST be called even if wait() has returned
/// as their are additional resources to free.
public func stop() async throws {
try await self.state.withLock { state in
// Allow stop to be called multiple times.
if case .stopped = state {
return
}
let vm: any VirtualMachineInstance
let relayManager: UnixSocketRelayManager
let startedState = try? state.startedState("stop")
if let startedState {
vm = startedState.vm
relayManager = startedState.relayManager
} else {
let createdState = try state.createdState("stop")
vm = createdState.vm
relayManager = createdState.relayManager
}
var firstError: Error?
do {
try await relayManager.stopAll()
} catch {
self.logger?.error("failed to stop relay manager: \(error)")
firstError = firstError ?? error
}
do {
try await vm.withAgent { agent in
// First, we need to stop any unix socket relays as this will
// keep the rootfs from being able to umount (EBUSY).
let sockets = self.config.sockets
if !sockets.isEmpty {
guard let relayAgent = agent as? SocketRelayAgent else {
throw ContainerizationError(
.unsupported,
message: "VirtualMachineAgent does not support relaySocket surface"
)
}
for socket in sockets {
try await relayAgent.stopSocketRelay(configuration: socket)
}
}
if let _ = startedState {
// Now lets ensure every process is donezo.
try await agent.kill(pid: -1, signal: SIGKILL)
// Wait on init proc exit. Give it 5 seconds of leeway.
_ = try await agent.waitProcess(
id: self.id,
containerID: self.id,
timeoutInSeconds: 5
)
}
// Today, we leave EBUSY looping and other fun logic up to the
// guest agent.
try await agent.umount(
path: Self.guestRootfsPath(self.id),
flags: 0
)
// If we have a writable layer, we also need to unmount the lower and upper layers.
if self.writableLayer != nil {
let upperPath = "/run/container/\(self.id)/upper"
let lowerPath = "/run/container/\(self.id)/lower"
try await agent.umount(path: upperPath, flags: 0)
try await agent.umount(path: lowerPath, flags: 0)
}
try await agent.sync()
}
} catch {
self.logger?.error("failed during guest cleanup: \(error)")
firstError = firstError ?? error
}
if let startedState {
for process in startedState.vendedProcesses.values {
do {
try await process._delete()
} catch {
self.logger?.error("failed to delete process \(process.id): \(error)")
firstError = firstError ?? error
}
}
do {
try await startedState.process.delete()
} catch {
self.logger?.error("failed to delete init process: \(error)")
firstError = firstError ?? error
}
}
do {
try await vm.stop()
state = .stopped
if let firstError {
throw firstError
}
} catch {
self.logger?.error("failed to stop VM: \(error)")
let finalError = firstError ?? error
state.setErrored(error: finalError)
throw finalError
}
}
}
/// Send a signal to the container.
public func kill(_ signal: Signal) async throws {
try await self.state.withLock {
let state = try $0.startedState("kill")
try await state.process.kill(signal)
}
}
/// Wait for the container to exit. Returns the exit code.
@discardableResult
public func wait(timeoutInSeconds: Int64? = nil) async throws -> ExitStatus {
let t = try await self.state.withLock {
let state = try $0.startedState("wait")
let t = Task {
try await state.process.wait(timeoutInSeconds: timeoutInSeconds)
}
return t
}
return try await t.value
}
/// Resize the container's terminal (if one was requested). This
/// will error if terminal was set to false before creating the container.
public func resize(to: Terminal.Size) async throws {
try await self.state.withLock {
let state = try $0.startedState("resize")
try await state.process.resize(to: to)
}
}
/// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method.
public func exec(_ id: String, configuration: @Sendable @escaping (inout LinuxProcessConfiguration) throws -> Void) async throws -> LinuxProcess {
try await self.state.withLock { state in
var startedState = try state.startedState("exec")
var spec = self.generateRuntimeSpec()
var config = LinuxProcessConfiguration()
try configuration(&config)
spec.process = config.toOCI()
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: config.stdin,
stdout: config.stdout,
stderr: config.stderr
)
let agent = try await startedState.vm.dialAgent()
let process = LinuxProcess(
id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: startedState.vm,
logger: self.logger,
onDelete: { [weak self = self] in
await self?.removeProcess(id: id)
}
)
startedState.vendedProcesses[id] = process
state = .started(startedState)
return process
}
}
/// Execute a new process in the container. The process is not started after this call, and must be manually started
/// via the `start` method.
public func exec(_ id: String, configuration: LinuxProcessConfiguration) async throws -> LinuxProcess {
try await self.state.withLock {
var state = try $0.startedState("exec")
var spec = self.generateRuntimeSpec()
spec.process = configuration.toOCI()
let stdio = IOUtil.setup(
portAllocator: self.hostVsockPorts,
stdin: configuration.stdin,
stdout: configuration.stdout,
stderr: configuration.stderr
)
let agent = try await state.vm.dialAgent()
let process = LinuxProcess(
id,
containerID: self.id,
spec: spec,
io: stdio,
ociRuntimePath: self.config.ociRuntimePath,
agent: agent,
vm: state.vm,
logger: self.logger,
onDelete: { [weak self = self] in
await self?.removeProcess(id: id)
}
)
state.vendedProcesses[id] = process
$0 = .started(state)
return process
}
}
/// Dial a vsock port in the container.
public func dialVsock(port: UInt32) async throws -> FileHandle {
try await self.state.withLock {
let state = try $0.startedState("dialVsock")
return try await state.vm.dial(port)
}
}
/// Provides scoped access to the underlying virtual machine instance.
///
/// Most users should prefer the higher level APIs on ``LinuxContainer``
/// directly. This is intended for advanced use cases that need to interact
/// with the virtual machine outside of the container abstraction.
public func withVirtualMachineInstance<T: Sendable>(
_ fn: @Sendable (any VirtualMachineInstance) async throws -> T
) async throws -> T {
let vm = try await self.state.withLock { state in
try state.vm("withVirtualMachineInstance")
}
return try await fn(vm)
}
/// Close the containers standard input to signal no more input is
/// arriving.
public func closeStdin() async throws {
try await self.state.withLock {
let state = try $0.startedState("closeStdin")
return try await state.process.closeStdin()
}
}
/// Remove a process from the vended processes tracking.
private func removeProcess(id: String) async {
await self.state.withLock {
guard case .started(var state) = $0 else {
return
}
state.vendedProcesses.removeValue(forKey: id)
$0 = .started(state)
}
}
/// Get statistics for the container.
public func statistics(categories: StatCategory = .all) async throws -> ContainerStatistics {
try await self.state.withLock {
let state = try $0.startedState("statistics")
let stats = try await state.vm.withAgent { agent in
let allStats = try await agent.containerStatistics(containerIDs: [self.id], categories: categories)
guard let containerStats = allStats.first else {
throw ContainerizationError(
.notFound,
message: "statistics for container \(self.id) not found"
)
}
return containerStats
}
return stats
}
}
// Perform filesystem operations in the container.
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
try await self.state.withLock {
let state = try $0.startedState("filesystemOperation")
try await state.vm.withAgent { agent in
guard let vminitd = agent as? Vminitd else {
throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent")
}
let guestPath = URL(filePath: Self.guestRootfsPath(self.id)).appending(path: path).path
try await vminitd.filesystemOperation(operation: operation, path: guestPath)
}
}
}
private func relayUnixSocket(
socket: UnixSocketConfiguration,
relayManager: UnixSocketRelayManager,
agent: any VirtualMachineAgent
) async throws {
guard let relayAgent = agent as? SocketRelayAgent else {
throw ContainerizationError(
.unsupported,
message: "VirtualMachineAgent does not support relaySocket surface"
)
}
var socket = socket
let rootInGuest = URL(filePath: self.root)
let port: UInt32
if socket.direction == .into {
port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
socket.destination = URL(filePath: Self.guestSocketStagingPath(socket.id))
} else {
port = self.guestVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
socket.source = rootInGuest.appending(path: socket.source.path)
}
try await relayManager.start(port: port, socket: socket)
try await relayAgent.relaySocket(port: port, configuration: socket)
}
/// Default chunk size for file transfers (1MiB).
public static let defaultCopyChunkSize = 1024 * 1024
/// Copy a file or directory from the host into the container.
///
/// Data transfer happens over a dedicated vsock connection. For directories,
/// the source is archived as tar+gzip and streamed directly through vsock
/// without intermediate temp files.
public func copyIn(
from source: URL,
to destination: URL,
mode: UInt32 = 0o644,
createParents: Bool = true,
chunkSize: Int = defaultCopyChunkSize
) async throws {
try await self.state.withLock {
let state = try $0.startedState("copyIn")
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: source.path, isDirectory: &isDirectory) else {
throw ContainerizationError(.notFound, message: "copyIn: source not found '\(source.path)'")
}
let isArchive = isDirectory.boolValue
let guestPath: URL = try await state.vm.withAgent { agent in
guard let vminitd = agent as? Vminitd else {
throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent")
}
return try await self.resolveCopyInGuestPath(
from: source,
to: destination,
sourceIsDirectory: isArchive,
using: vminitd
)
}
let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
let listener = try state.vm.listen(port)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await state.vm.withAgent { agent in
guard let vminitd = agent as? Vminitd else {
throw ContainerizationError(.unsupported, message: "copyIn requires Vminitd agent")
}
try await vminitd.copy(
direction: .copyIn,
guestPath: guestPath,
vsockPort: port,
mode: mode,
createParents: createParents,
isArchive: isArchive
)
}
}
group.addTask {
guard let conn = await listener.first(where: { _ in true }) else {
throw ContainerizationError(.internalError, message: "copyIn: vsock connection not established")
}
try listener.finish()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
self.copyQueue.async {
do {
defer { conn.closeFile() }
if isArchive {
let writer = try ArchiveWriter(configuration: .init(format: .pax, filter: .gzip))
try writer.open(fileDescriptor: conn.fileDescriptor)
try writer.archiveDirectory(source)
try writer.finishEncoding()
} else {
let srcFd = open(source.path, O_RDONLY)
guard srcFd != -1 else {
throw ContainerizationError(
.internalError,
message: "copyIn: failed to open '\(source.path)': \(String(cString: strerror(errno)))"
)
}
defer { close(srcFd) }
var buf = [UInt8](repeating: 0, count: chunkSize)
while true {
let n = read(srcFd, &buf, buf.count)
if n == 0 { break }
guard n > 0 else {
throw ContainerizationError(
.internalError,
message: "copyIn: read error: \(String(cString: strerror(errno)))"
)
}
var written = 0
while written < n {
let w = buf.withUnsafeBytes { ptr in
write(conn.fileDescriptor, ptr.baseAddress! + written, n - written)
}
guard w > 0 else {
throw ContainerizationError(
.internalError,
message: "copyIn: vsock write error: \(String(cString: strerror(errno)))"
)
}
written += w
}
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
try await group.waitForAll()
}
}
}
private func resolveCopyInGuestPath(
from source: URL,
to destination: URL,
sourceIsDirectory: Bool,
using vminitd: Vminitd
) async throws -> URL {
let guestDestination = URL(filePath: self.root).appending(path: destination.path)
let stat: ContainerizationOS.Stat?
do {
stat = try await vminitd.stat(path: guestDestination)
} catch let error as ContainerizationError where error.code == .notFound {
stat = nil
}
// Any other error propagates so transport and permission failures are visible.
guard let stat else {
if destination.hasDirectoryPath && !sourceIsDirectory {
throw ContainerizationError(
.invalidArgument,
message: "destination directory does not exist: \(destination.path)"
)
}
return guestDestination
}
let destinationIsDirectory = (stat.mode & UInt32(S_IFMT)) == UInt32(S_IFDIR)
guard destinationIsDirectory else {
if sourceIsDirectory {
throw ContainerizationError(
.invalidArgument,
message: "cannot copy directory over existing file: \(destination.path)"
)
}
return guestDestination
}
return guestDestination.appendingPathComponent(source.lastPathComponent)
}
/// Copy a file or directory from the container to the host.
///
/// Data transfer happens over a dedicated vsock connection. For directories,
/// the guest archives the source as tar+gzip and streams it directly through
/// vsock. The host extracts the archive without intermediate temp files.
public func copyOut(
from source: URL,
to destination: URL,
createParents: Bool = true,
chunkSize: Int = defaultCopyChunkSize
) async throws {
try await self.state.withLock {
let state = try $0.startedState("copyOut")
if createParents {
let parentDir = destination.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
}
let guestPath = URL(filePath: self.root).appending(path: source.path)
let port = self.hostVsockPorts.wrappingAdd(1, ordering: .relaxed).oldValue
let listener = try state.vm.listen(port)
let (metadataStream, metadataCont) = AsyncStream.makeStream(of: Vminitd.CopyMetadata.self)
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await state.vm.withAgent { agent in
guard let vminitd = agent as? Vminitd else {
throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent")
}
try await vminitd.copy(
direction: .copyOut,
guestPath: guestPath,
vsockPort: port,
onMetadata: { meta in
metadataCont.yield(meta)
metadataCont.finish()
}
)
}
}
group.addTask {
guard let metadata = await metadataStream.first(where: { _ in true }) else {
throw ContainerizationError(.internalError, message: "copyOut: no metadata received")
}
guard let conn = await listener.first(where: { _ in true }) else {
throw ContainerizationError(.internalError, message: "copyOut: vsock connection not established")
}
try listener.finish()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
self.copyQueue.async {
do {
defer { conn.closeFile() }
if metadata.isArchive {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
let fh = FileHandle(fileDescriptor: dup(conn.fileDescriptor), closeOnDealloc: true)
let reader = try ArchiveReader(format: .pax, filter: .gzip, fileHandle: fh)
_ = try reader.extractContents(to: destination)
} else {
let destFd = open(destination.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
guard destFd != -1 else {
throw ContainerizationError(
.internalError,
message: "copyOut: failed to open '\(destination.path)': \(String(cString: strerror(errno)))"
)
}
defer { close(destFd) }
var buf = [UInt8](repeating: 0, count: chunkSize)
while true {
let n = read(conn.fileDescriptor, &buf, buf.count)
if n == 0 { break }
guard n > 0 else {
throw ContainerizationError(
.internalError,
message: "copyOut: vsock read error: \(String(cString: strerror(errno)))"
)
}
var written = 0
while written < n {
let w = buf.withUnsafeBytes { ptr in
write(destFd, ptr.baseAddress! + written, n - written)
}
guard w > 0 else {
throw ContainerizationError(
.internalError,
message: "copyOut: write error: \(String(cString: strerror(errno)))"
)
}
written += w
}
}
}
continuation.resume()
} catch {
continuation.resume(throwing: error)
}
}
}
}
try await group.waitForAll()
}
}
}
}
extension VirtualMachineInstance {
/// Scoped access to an agent instance to ensure the resources are always freed (mostly close(2)'ing
/// the vsock fd)
func withAgent<T>(fn: @Sendable (VirtualMachineAgent) async throws -> T) async throws -> T {
let agent = try await self.dialAgent()
do {
let result = try await fn(agent)
try await agent.close()
return result
} catch {
try? await agent.close()
throw error
}
}
}
extension AttachedFilesystem {
var to: ContainerizationOCI.Mount {
.init(
type: self.type,
source: self.source,
destination: self.destination,
options: self.options
)
}
}
/// Normalize mount destinations via ``FilePath/lexicallyNormalized()`` and
/// sort mounts by the depth of their destination path. This ensures that
/// higher level mounts don't shadow other mounts. For example, if a user
/// specifies mounts for `/tmp/foo/bar` and `/tmp`, sorting by depth ensures
/// `/tmp` is mounted first without shadowing `/tmp/foo/bar`.
func cleanAndSortMounts(_ mounts: [ContainerizationOCI.Mount]) -> [ContainerizationOCI.Mount] {
var mounts = mounts
for i in mounts.indices {
mounts[i].destination = FilePath(mounts[i].destination).lexicallyNormalized().string
}
return sortMountsByDestinationDepth(mounts)
}
/// Sort mounts by the depth of their destination path.
func sortMountsByDestinationDepth(_ mounts: [ContainerizationOCI.Mount]) -> [ContainerizationOCI.Mount] {
mounts.sorted { a, b in
a.destination.split(separator: "/").count < b.destination.split(separator: "/").count
}
}
struct IOUtil {
static func setup(
portAllocator: borrowing Atomic<UInt32>,
stdin: ReaderStream?,
stdout: Writer?,
stderr: Writer?
) -> LinuxProcess.Stdio {
var stdinSetup: LinuxProcess.StdioReaderSetup? = nil
if let reader = stdin {
let ret = portAllocator.wrappingAdd(1, ordering: .relaxed)
stdinSetup = .init(
port: ret.oldValue,
reader: reader
)
}
var stdoutSetup: LinuxProcess.StdioSetup? = nil
if let writer = stdout {
let ret = portAllocator.wrappingAdd(1, ordering: .relaxed)
stdoutSetup = LinuxProcess.StdioSetup(
port: ret.oldValue,
writer: writer
)
}
var stderrSetup: LinuxProcess.StdioSetup? = nil
if let writer = stderr {
let ret = portAllocator.wrappingAdd(1, ordering: .relaxed)
stderrSetup = LinuxProcess.StdioSetup(
port: ret.oldValue,
writer: writer
)
}
return LinuxProcess.Stdio(
stdin: stdinSetup,
stdout: stdoutSetup,
stderr: stderrSetup
)
}
}