Vendor apple/containerization with a VM-extensions forwarding patch
Switch the containerization dependency from the github URL to a vendored copy (third_party/containerization, upstream commit 6b7b42ca) referenced by path, so we can carry a small local patch that upstream lacks: LinuxContainer.Configuration gains a `vmExtensions` field forwarded into VMConfiguration.extensions. Upstream already supports VMConfiguration.extensions + the VZInstanceExtension hook, but LinuxContainer — our only entry point — never forwarded them, so there was no way to attach a device (e.g. a memory balloon) to a container's VM. Tests/, docs/, examples/, images/ and the corresponding test targets are trimmed for footprint (we never build the dependency's tests). See PATCHES.md for the full diff vs. upstream and the re-vendoring procedure. Also adds the ContainerizationExtras product to NucleicCore (AddressAllocator, named in the configureVZ signature). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
|
||||
/// A filesystem that was attached and able to be mounted inside the runtime environment.
|
||||
public struct AttachedFilesystem: Sendable {
|
||||
/// The type of the filesystem.
|
||||
public var type: String
|
||||
/// The path to the filesystem within a sandbox.
|
||||
public var source: String
|
||||
/// Destination when mounting the filesystem inside a sandbox.
|
||||
public var destination: String
|
||||
/// The options to use when mounting the filesystem.
|
||||
public var options: [String]
|
||||
|
||||
public init(mount: Mount, allocator: any AddressAllocator<Character>) throws {
|
||||
switch mount.runtimeOptions {
|
||||
case .virtiofs:
|
||||
let name = try hashFilePath(path: mount.source)
|
||||
self.source = name
|
||||
case .virtioblk:
|
||||
let char = try allocator.allocate()
|
||||
self.source = "/dev/vd\(char)"
|
||||
case .shared, .any:
|
||||
self.source = mount.source
|
||||
}
|
||||
self.type = mount.type
|
||||
self.options = mount.options
|
||||
self.destination = mount.destination
|
||||
}
|
||||
|
||||
public init(type: String, source: String, destination: String, options: [String]) {
|
||||
self.type = type
|
||||
self.source = source
|
||||
self.destination = destination
|
||||
self.options = options
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 core protocol container implementations must implement.
|
||||
public protocol Container {
|
||||
/// ID for the container.
|
||||
var id: String { get }
|
||||
/// The amount of cpus assigned to the container.
|
||||
var cpus: Int { get }
|
||||
/// The memory in bytes assigned to the container.
|
||||
var memoryInBytes: UInt64 { get }
|
||||
/// The network interfaces assigned to the container.
|
||||
var interfaces: [any Interface] { get }
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationEXT4
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import ContainerizationExtras
|
||||
import SystemPackage
|
||||
import Virtualization
|
||||
|
||||
/// A manager for creating and running containers.
|
||||
/// Supports container networking options.
|
||||
public struct ContainerManager: Sendable {
|
||||
public let imageStore: ImageStore
|
||||
private let vmm: VirtualMachineManager
|
||||
private var network: Network?
|
||||
|
||||
private var containerRoot: URL {
|
||||
self.imageStore.path.appendingPathComponent("containers")
|
||||
}
|
||||
|
||||
/// Create a new manager with the provided kernel, initfs mount, image store
|
||||
/// and optional network implementation. This will use a Virtualization.framework
|
||||
/// backed VMM implicitly.
|
||||
public init(
|
||||
kernel: Kernel,
|
||||
initfs: Mount,
|
||||
imageStore: ImageStore,
|
||||
network: Network? = nil,
|
||||
rosetta: Bool = false,
|
||||
nestedVirtualization: Bool = false
|
||||
) throws {
|
||||
self.imageStore = imageStore
|
||||
self.network = network
|
||||
try Self.createRootDirectory(path: self.imageStore.path)
|
||||
self.vmm = VZVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initfs,
|
||||
rosetta: rosetta,
|
||||
nestedVirtualization: nestedVirtualization
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new manager with the provided kernel, initfs mount, root state
|
||||
/// directory and optional network implementation. This will use a Virtualization.framework
|
||||
/// backed VMM implicitly.
|
||||
public init(
|
||||
kernel: Kernel,
|
||||
initfs: Mount,
|
||||
root: URL? = nil,
|
||||
network: Network? = nil,
|
||||
rosetta: Bool = false,
|
||||
nestedVirtualization: Bool = false
|
||||
) throws {
|
||||
if let root {
|
||||
self.imageStore = try ImageStore(path: root)
|
||||
} else {
|
||||
self.imageStore = ImageStore.default
|
||||
}
|
||||
self.network = network
|
||||
try Self.createRootDirectory(path: self.imageStore.path)
|
||||
self.vmm = VZVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initfs,
|
||||
rosetta: rosetta,
|
||||
nestedVirtualization: nestedVirtualization
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new manager with the provided kernel, initfs reference, image store
|
||||
/// and optional network implementation. This will use a Virtualization.framework
|
||||
/// backed VMM implicitly.
|
||||
public init(
|
||||
kernel: Kernel,
|
||||
initfsReference: String,
|
||||
imageStore: ImageStore,
|
||||
network: Network? = nil,
|
||||
rosetta: Bool = false,
|
||||
nestedVirtualization: Bool = false
|
||||
) async throws {
|
||||
self.imageStore = imageStore
|
||||
self.network = network
|
||||
try Self.createRootDirectory(path: self.imageStore.path)
|
||||
|
||||
let initPath = self.imageStore.path.appendingPathComponent("initfs.ext4")
|
||||
let initImage = try await self.imageStore.getInitImage(reference: initfsReference)
|
||||
let initfs = try await {
|
||||
do {
|
||||
return try await initImage.initBlock(at: initPath, for: .linuxArm)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code == .exists else {
|
||||
throw err
|
||||
}
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: initPath.absolutePath(),
|
||||
destination: "/",
|
||||
options: ["ro"]
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
self.vmm = VZVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initfs,
|
||||
rosetta: rosetta,
|
||||
nestedVirtualization: nestedVirtualization
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new manager with the provided kernel and image reference for the initfs.
|
||||
/// This will use a Virtualization.framework backed VMM implicitly.
|
||||
public init(
|
||||
kernel: Kernel,
|
||||
initfsReference: String,
|
||||
root: URL? = nil,
|
||||
network: Network? = nil,
|
||||
rosetta: Bool = false,
|
||||
nestedVirtualization: Bool = false
|
||||
) async throws {
|
||||
if let root {
|
||||
self.imageStore = try ImageStore(path: root)
|
||||
} else {
|
||||
self.imageStore = ImageStore.default
|
||||
}
|
||||
self.network = network
|
||||
try Self.createRootDirectory(path: self.imageStore.path)
|
||||
|
||||
let initPath = self.imageStore.path.appendingPathComponent("initfs.ext4")
|
||||
let initImage = try await self.imageStore.getInitImage(reference: initfsReference)
|
||||
let initfs = try await {
|
||||
do {
|
||||
return try await initImage.initBlock(at: initPath, for: .linuxArm)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code == .exists else {
|
||||
throw err
|
||||
}
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: initPath.absolutePath(),
|
||||
destination: "/",
|
||||
options: ["ro"]
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
self.vmm = VZVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initfs,
|
||||
rosetta: rosetta,
|
||||
nestedVirtualization: nestedVirtualization
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new manager with the provided vmm and network.
|
||||
public init(
|
||||
vmm: any VirtualMachineManager,
|
||||
network: Network? = nil
|
||||
) throws {
|
||||
self.imageStore = ImageStore.default
|
||||
try Self.createRootDirectory(path: self.imageStore.path)
|
||||
self.network = network
|
||||
self.vmm = vmm
|
||||
}
|
||||
|
||||
private static func createRootDirectory(path: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: path.appendingPathComponent("containers"),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new container from the provided image reference.
|
||||
/// - Parameters:
|
||||
/// - id: The container ID.
|
||||
/// - reference: The image reference.
|
||||
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
|
||||
/// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided,
|
||||
/// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size.
|
||||
/// - readOnly: Whether to mount the root filesystem as read-only.
|
||||
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
|
||||
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
|
||||
/// - progress: Optional handler for tracking rootfs unpacking progress.
|
||||
public mutating func create(
|
||||
_ id: String,
|
||||
reference: String,
|
||||
rootfsSizeInBytes: UInt64 = 8.gib(),
|
||||
writableLayerSizeInBytes: UInt64? = nil,
|
||||
readOnly: Bool = false,
|
||||
networking: Bool = true,
|
||||
progress: ProgressHandler? = nil,
|
||||
configuration: (inout LinuxContainer.Configuration) throws -> Void
|
||||
) async throws -> LinuxContainer {
|
||||
let image = try await imageStore.get(reference: reference, pull: true)
|
||||
return try await create(
|
||||
id,
|
||||
image: image,
|
||||
rootfsSizeInBytes: rootfsSizeInBytes,
|
||||
writableLayerSizeInBytes: writableLayerSizeInBytes,
|
||||
readOnly: readOnly,
|
||||
networking: networking,
|
||||
progress: progress,
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new container from the provided image.
|
||||
/// - Parameters:
|
||||
/// - id: The container ID.
|
||||
/// - image: The image.
|
||||
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
|
||||
/// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided,
|
||||
/// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size.
|
||||
/// - readOnly: Whether to mount the root filesystem as read-only.
|
||||
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
|
||||
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
|
||||
/// - progress: Optional handler for tracking rootfs unpacking progress.
|
||||
public mutating func create(
|
||||
_ id: String,
|
||||
image: Image,
|
||||
rootfsSizeInBytes: UInt64 = 8.gib(),
|
||||
writableLayerSizeInBytes: UInt64? = nil,
|
||||
readOnly: Bool = false,
|
||||
networking: Bool = true,
|
||||
progress: ProgressHandler? = nil,
|
||||
configuration: (inout LinuxContainer.Configuration) throws -> Void
|
||||
) async throws -> LinuxContainer {
|
||||
let path = try createContainerRoot(id)
|
||||
|
||||
var rootfs = try await unpack(
|
||||
image: image,
|
||||
destination: path.appendingPathComponent("rootfs.ext4"),
|
||||
size: rootfsSizeInBytes,
|
||||
progress: progress
|
||||
)
|
||||
if readOnly {
|
||||
rootfs.options.append("ro")
|
||||
}
|
||||
|
||||
// Create writable layer if size is specified.
|
||||
var writableLayer: Mount? = nil
|
||||
if let writableLayerSize = writableLayerSizeInBytes {
|
||||
writableLayer = try createEmptyFilesystem(
|
||||
at: path.appendingPathComponent("writable.ext4"),
|
||||
size: writableLayerSize
|
||||
)
|
||||
}
|
||||
|
||||
return try await create(
|
||||
id,
|
||||
image: image,
|
||||
rootfs: rootfs,
|
||||
writableLayer: writableLayer,
|
||||
networking: networking,
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new container from the provided image and root filesystem mount.
|
||||
/// - Parameters:
|
||||
/// - id: The container ID.
|
||||
/// - image: The image.
|
||||
/// - rootfs: The root filesystem mount pointing to an existing block file.
|
||||
/// 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.
|
||||
/// The `destination` field is ignored as mounting is handled internally.
|
||||
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
|
||||
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
|
||||
public mutating func create(
|
||||
_ id: String,
|
||||
image: Image,
|
||||
rootfs: Mount,
|
||||
writableLayer: Mount? = nil,
|
||||
networking: Bool = true,
|
||||
configuration: (inout LinuxContainer.Configuration) throws -> Void
|
||||
) async throws -> LinuxContainer {
|
||||
let imageConfig = try await image.config(for: .current).config
|
||||
return try LinuxContainer(
|
||||
id,
|
||||
rootfs: rootfs,
|
||||
writableLayer: writableLayer,
|
||||
vmm: self.vmm
|
||||
) { config in
|
||||
if let imageConfig {
|
||||
config.process = .init(from: imageConfig)
|
||||
}
|
||||
if networking {
|
||||
if let interface = try self.network?.createInterface(id) {
|
||||
config.interfaces = [interface]
|
||||
guard let gateway = interface.ipv4Gateway else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "missing ipv4 gateway for container \(id)"
|
||||
)
|
||||
}
|
||||
config.dns = .init(nameservers: [gateway.description])
|
||||
}
|
||||
}
|
||||
config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log"))
|
||||
try configuration(&config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases network resources for a container.
|
||||
///
|
||||
/// - Parameter id: The container ID.
|
||||
public mutating func releaseNetwork(_ id: String) throws {
|
||||
try self.network?.releaseInterface(id)
|
||||
}
|
||||
|
||||
/// Releases network resources and removes all files for a container.
|
||||
/// - Parameter id: The container ID.
|
||||
public mutating func delete(_ id: String) throws {
|
||||
try self.releaseNetwork(id)
|
||||
let path = containerRoot.appendingPathComponent(id)
|
||||
try FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
private func createContainerRoot(_ id: String) throws -> URL {
|
||||
let path = containerRoot.appendingPathComponent(id)
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: false)
|
||||
return path
|
||||
}
|
||||
|
||||
private func unpack(image: Image, destination: URL, size: UInt64, progress: ProgressHandler? = nil) async throws -> Mount {
|
||||
do {
|
||||
let unpacker = EXT4Unpacker(blockSizeInBytes: size)
|
||||
return try await unpacker.unpack(image, for: .current, at: destination, progress: progress)
|
||||
} catch let err as ContainerizationError {
|
||||
if err.code == .exists {
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: destination.absolutePath(),
|
||||
destination: "/",
|
||||
options: []
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private func createEmptyFilesystem(at destination: URL, size: UInt64) throws -> Mount {
|
||||
let path = destination.absolutePath()
|
||||
guard !FileManager.default.fileExists(atPath: path) else {
|
||||
throw ContainerizationError(.exists, message: "filesystem already exists at \(path)")
|
||||
}
|
||||
let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: size)
|
||||
try filesystem.close()
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: path,
|
||||
destination: "/",
|
||||
options: []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension CIDRv4 {
|
||||
/// The gateway address of the network.
|
||||
public var gateway: IPv4Address {
|
||||
IPv4Address(self.lower.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
extension CIDRv6 {
|
||||
/// The gateway address of the network.
|
||||
public var gateway: IPv6Address {
|
||||
IPv6Address(self.lower.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,248 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// Statistics for a container.
|
||||
public struct ContainerStatistics: Sendable {
|
||||
public var id: String
|
||||
public var process: ProcessStatistics?
|
||||
public var memory: MemoryStatistics?
|
||||
public var cpu: CPUStatistics?
|
||||
public var blockIO: BlockIOStatistics?
|
||||
public var networks: [NetworkStatistics]?
|
||||
public var memoryEvents: MemoryEventStatistics?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
process: ProcessStatistics? = nil,
|
||||
memory: MemoryStatistics? = nil,
|
||||
cpu: CPUStatistics? = nil,
|
||||
blockIO: BlockIOStatistics? = nil,
|
||||
networks: [NetworkStatistics]? = nil,
|
||||
memoryEvents: MemoryEventStatistics? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.process = process
|
||||
self.memory = memory
|
||||
self.cpu = cpu
|
||||
self.blockIO = blockIO
|
||||
self.networks = networks
|
||||
self.memoryEvents = memoryEvents
|
||||
}
|
||||
|
||||
/// Process statistics for a container.
|
||||
public struct ProcessStatistics: Sendable {
|
||||
public var current: UInt64
|
||||
public var limit: UInt64
|
||||
|
||||
public init(current: UInt64, limit: UInt64) {
|
||||
self.current = current
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory statistics for a container.
|
||||
public struct MemoryStatistics: Sendable {
|
||||
public var usageBytes: UInt64
|
||||
public var limitBytes: UInt64
|
||||
public var swapUsageBytes: UInt64
|
||||
public var swapLimitBytes: UInt64
|
||||
public var cacheBytes: UInt64
|
||||
public var kernelStackBytes: UInt64
|
||||
public var slabBytes: UInt64
|
||||
public var pageFaults: UInt64
|
||||
public var majorPageFaults: UInt64
|
||||
public var inactiveFile: UInt64
|
||||
public var anon: UInt64
|
||||
public var workingsetRefaultAnon: UInt64
|
||||
public var workingsetRefaultFile: UInt64
|
||||
public var pgstealKswapd: UInt64
|
||||
public var pgstealDirect: UInt64
|
||||
public var pgstealKhugepaged: UInt64
|
||||
|
||||
public init(
|
||||
usageBytes: UInt64,
|
||||
limitBytes: UInt64,
|
||||
swapUsageBytes: UInt64,
|
||||
swapLimitBytes: UInt64,
|
||||
cacheBytes: UInt64,
|
||||
kernelStackBytes: UInt64,
|
||||
slabBytes: UInt64,
|
||||
pageFaults: UInt64,
|
||||
majorPageFaults: UInt64,
|
||||
inactiveFile: UInt64,
|
||||
anon: UInt64,
|
||||
workingsetRefaultAnon: UInt64 = 0,
|
||||
workingsetRefaultFile: UInt64 = 0,
|
||||
pgstealKswapd: UInt64 = 0,
|
||||
pgstealDirect: UInt64 = 0,
|
||||
pgstealKhugepaged: UInt64 = 0
|
||||
) {
|
||||
self.usageBytes = usageBytes
|
||||
self.limitBytes = limitBytes
|
||||
self.swapUsageBytes = swapUsageBytes
|
||||
self.swapLimitBytes = swapLimitBytes
|
||||
self.cacheBytes = cacheBytes
|
||||
self.kernelStackBytes = kernelStackBytes
|
||||
self.slabBytes = slabBytes
|
||||
self.pageFaults = pageFaults
|
||||
self.majorPageFaults = majorPageFaults
|
||||
self.inactiveFile = inactiveFile
|
||||
self.anon = anon
|
||||
self.workingsetRefaultAnon = workingsetRefaultAnon
|
||||
self.workingsetRefaultFile = workingsetRefaultFile
|
||||
self.pgstealKswapd = pgstealKswapd
|
||||
self.pgstealDirect = pgstealDirect
|
||||
self.pgstealKhugepaged = pgstealKhugepaged
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU statistics for a container.
|
||||
public struct CPUStatistics: Sendable {
|
||||
public var usageUsec: UInt64
|
||||
public var userUsec: UInt64
|
||||
public var systemUsec: UInt64
|
||||
public var throttlingPeriods: UInt64
|
||||
public var throttledPeriods: UInt64
|
||||
public var throttledTimeUsec: UInt64
|
||||
|
||||
public init(
|
||||
usageUsec: UInt64,
|
||||
userUsec: UInt64,
|
||||
systemUsec: UInt64,
|
||||
throttlingPeriods: UInt64,
|
||||
throttledPeriods: UInt64,
|
||||
throttledTimeUsec: UInt64
|
||||
) {
|
||||
self.usageUsec = usageUsec
|
||||
self.userUsec = userUsec
|
||||
self.systemUsec = systemUsec
|
||||
self.throttlingPeriods = throttlingPeriods
|
||||
self.throttledPeriods = throttledPeriods
|
||||
self.throttledTimeUsec = throttledTimeUsec
|
||||
}
|
||||
}
|
||||
|
||||
/// Block I/O statistics for a container.
|
||||
public struct BlockIOStatistics: Sendable {
|
||||
public var devices: [BlockIODevice]
|
||||
|
||||
public init(devices: [BlockIODevice]) {
|
||||
self.devices = devices
|
||||
}
|
||||
}
|
||||
|
||||
/// Block I/O statistics for a specific device.
|
||||
public struct BlockIODevice: Sendable {
|
||||
public var major: UInt64
|
||||
public var minor: UInt64
|
||||
public var readBytes: UInt64
|
||||
public var writeBytes: UInt64
|
||||
public var readOperations: UInt64
|
||||
public var writeOperations: UInt64
|
||||
|
||||
public init(
|
||||
major: UInt64,
|
||||
minor: UInt64,
|
||||
readBytes: UInt64,
|
||||
writeBytes: UInt64,
|
||||
readOperations: UInt64,
|
||||
writeOperations: UInt64
|
||||
) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.readBytes = readBytes
|
||||
self.writeBytes = writeBytes
|
||||
self.readOperations = readOperations
|
||||
self.writeOperations = writeOperations
|
||||
}
|
||||
}
|
||||
|
||||
/// Statistics for a network interface.
|
||||
public struct NetworkStatistics: Sendable {
|
||||
public var interface: String
|
||||
public var receivedPackets: UInt64
|
||||
public var transmittedPackets: UInt64
|
||||
public var receivedBytes: UInt64
|
||||
public var transmittedBytes: UInt64
|
||||
public var receivedErrors: UInt64
|
||||
public var transmittedErrors: UInt64
|
||||
|
||||
public init(
|
||||
interface: String,
|
||||
receivedPackets: UInt64,
|
||||
transmittedPackets: UInt64,
|
||||
receivedBytes: UInt64,
|
||||
transmittedBytes: UInt64,
|
||||
receivedErrors: UInt64,
|
||||
transmittedErrors: UInt64
|
||||
) {
|
||||
self.interface = interface
|
||||
self.receivedPackets = receivedPackets
|
||||
self.transmittedPackets = transmittedPackets
|
||||
self.receivedBytes = receivedBytes
|
||||
self.transmittedBytes = transmittedBytes
|
||||
self.receivedErrors = receivedErrors
|
||||
self.transmittedErrors = transmittedErrors
|
||||
}
|
||||
}
|
||||
|
||||
/// Memory event counters from cgroup2's memory.events file.
|
||||
public struct MemoryEventStatistics: Sendable {
|
||||
/// Number of times the cgroup was reclaimed due to low memory.
|
||||
public var low: UInt64
|
||||
/// Number of times the cgroup exceeded its high memory limit.
|
||||
public var high: UInt64
|
||||
/// Number of times the cgroup hit its max memory limit.
|
||||
public var max: UInt64
|
||||
/// Number of times the cgroup triggered OOM.
|
||||
public var oom: UInt64
|
||||
/// Number of processes killed by OOM killer.
|
||||
public var oomKill: UInt64
|
||||
|
||||
public init(low: UInt64, high: UInt64, max: UInt64, oom: UInt64, oomKill: UInt64) {
|
||||
self.low = low
|
||||
self.high = high
|
||||
self.max = max
|
||||
self.oom = oom
|
||||
self.oomKill = oomKill
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Categories of statistics that can be requested.
|
||||
public struct StatCategory: OptionSet, Sendable {
|
||||
public let rawValue: Int
|
||||
|
||||
public init(rawValue: Int) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
/// Process statistics (pids.current, pids.max).
|
||||
public static let process = StatCategory(rawValue: 1 << 0)
|
||||
/// Memory usage statistics.
|
||||
public static let memory = StatCategory(rawValue: 1 << 1)
|
||||
/// CPU usage statistics.
|
||||
public static let cpu = StatCategory(rawValue: 1 << 2)
|
||||
/// Block I/O statistics.
|
||||
public static let blockIO = StatCategory(rawValue: 1 << 3)
|
||||
/// Network interface statistics.
|
||||
public static let network = StatCategory(rawValue: 1 << 4)
|
||||
/// Memory event counters (OOM kills, pressure events, etc.).
|
||||
public static let memoryEvents = StatCategory(rawValue: 1 << 5)
|
||||
|
||||
/// All available statistics categories.
|
||||
public static let all: StatCategory = [.process, .memory, .cpu, .blockIO, .network, .memoryEvents]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
|
||||
/// DNS configuration for a container. The values will be used to
|
||||
/// construct /etc/resolv.conf for a given container.
|
||||
public struct DNS: Sendable {
|
||||
/// The set of default nameservers to use if none are provided
|
||||
/// in the constructor.
|
||||
public static let defaultNameservers = ["1.1.1.1"]
|
||||
|
||||
/// The nameservers a container should use.
|
||||
public var nameservers: [String]
|
||||
/// The DNS domain to use.
|
||||
public var domain: String?
|
||||
/// The DNS search domains to use.
|
||||
public var searchDomains: [String]
|
||||
/// The DNS options to use.
|
||||
public var options: [String]
|
||||
|
||||
public init(
|
||||
nameservers: [String] = defaultNameservers,
|
||||
domain: String? = nil,
|
||||
searchDomains: [String] = [],
|
||||
options: [String] = []
|
||||
) {
|
||||
self.nameservers = nameservers
|
||||
self.domain = domain
|
||||
self.searchDomains = searchDomains
|
||||
self.options = options
|
||||
}
|
||||
|
||||
/// Validates the DNS configuration.
|
||||
///
|
||||
/// Ensures that all nameserver entries are valid IPv4 or IPv6 addresses.
|
||||
/// Arbitrary hostnames are not permitted as nameservers.
|
||||
///
|
||||
/// - Throws: ``ContainerizationError`` with code `.invalidArgument` if
|
||||
/// any nameserver is not a valid IP address.
|
||||
public func validate() throws {
|
||||
for nameserver in nameservers {
|
||||
let isValidIPv4 = (try? IPv4Address(nameserver)) != nil
|
||||
let isValidIPv6 = (try? IPv6Address(nameserver)) != nil
|
||||
if !isValidIPv4 && !isValidIPv6 {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "nameserver '\(nameserver)' is not a valid IPv4 or IPv6 address"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DNS {
|
||||
public var resolvConf: String {
|
||||
var text = ""
|
||||
|
||||
if !nameservers.isEmpty {
|
||||
text += nameservers.map { "nameserver \($0)" }.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
if let domain {
|
||||
text += "domain \(domain)\n"
|
||||
}
|
||||
|
||||
if !searchDomains.isEmpty {
|
||||
text += "search \(searchDomains.joined(separator: " "))\n"
|
||||
}
|
||||
|
||||
if !options.isEmpty {
|
||||
text += "options \(options.joined(separator: " "))\n"
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
/// ExitStatus contains the exit code for a given container process,
|
||||
/// as well as the timestamp at which it exited.
|
||||
public struct ExitStatus: Sendable {
|
||||
/// The exit code for the process.
|
||||
public var exitCode: Int32
|
||||
/// The timestamp when the process exited.
|
||||
public var exitedAt: Date
|
||||
|
||||
public init(exitCode: Int32) {
|
||||
self.exitCode = exitCode
|
||||
self.exitedAt = .now
|
||||
}
|
||||
|
||||
public init(exitCode: Int32, exitedAt: Date) {
|
||||
self.exitCode = exitCode
|
||||
self.exitedAt = exitedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// Manages single-file mounts by transforming them into virtiofs directory shares
|
||||
/// plus bind mounts.
|
||||
///
|
||||
/// Since virtiofs only supports sharing directories, mounting a single file requires
|
||||
/// sharing the file's parent directory via virtiofs and then bind mounting the specific
|
||||
/// file from that share to the final destination in the container.
|
||||
struct FileMountContext: Sendable {
|
||||
/// Metadata for a single prepared file mount.
|
||||
struct PreparedMount: Sendable {
|
||||
/// Original file path on host
|
||||
let hostFilePath: String
|
||||
/// Where the user wants the file in the container
|
||||
let containerDestination: String
|
||||
/// Just the filename (after resolving symlinks)
|
||||
let filename: String
|
||||
/// The parent directory containing the file (after resolving symlinks)
|
||||
let parentDirectory: URL
|
||||
/// The virtiofs tag (hash of parent dir path). Used to find the AttachedFilesystem
|
||||
let tag: String
|
||||
/// Mount options from the original mount
|
||||
let options: [String]
|
||||
/// Where we mounted the share in the guest (set after mountHoldingDirectories)
|
||||
var guestHoldingPath: String?
|
||||
}
|
||||
|
||||
/// Prepared file mounts for this context
|
||||
var preparedMounts: [PreparedMount]
|
||||
|
||||
/// The transformed mounts to pass to the VM (files replaced with directory shares)
|
||||
private(set) var transformedMounts: [Mount]
|
||||
|
||||
private init() {
|
||||
self.preparedMounts = []
|
||||
self.transformedMounts = []
|
||||
}
|
||||
|
||||
/// Returns true if there are any file mounts that need handling.
|
||||
var hasFileMounts: Bool {
|
||||
!preparedMounts.isEmpty
|
||||
}
|
||||
|
||||
/// Returns the set of virtiofs tags for file mount holding directories.
|
||||
/// These should be filtered out from OCI spec mounts since we mount them
|
||||
/// separately under /run.
|
||||
var holdingDirectoryTags: Set<String> {
|
||||
Set(preparedMounts.map { $0.tag })
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Prepare mounts for a container, detecting file mounts and transforming them.
|
||||
///
|
||||
/// This method stats each virtiofs mount source. If it's a regular file rather than
|
||||
/// a directory, it shares the file's parent directory via virtiofs and records the
|
||||
/// metadata needed to bind mount the specific file later.
|
||||
///
|
||||
/// - Parameter mounts: The original mounts from the container config
|
||||
/// - Returns: A FileMountContext containing transformed mounts and tracking info
|
||||
static func prepare(mounts: [Mount]) throws -> FileMountContext {
|
||||
var context = FileMountContext()
|
||||
var transformed: [Mount] = []
|
||||
// Track parent directories we've already added a share for to avoid duplicates.
|
||||
var sharedParentTags: Set<String> = []
|
||||
|
||||
for mount in mounts {
|
||||
// Only virtiofs mounts can be files
|
||||
guard case .virtiofs(let runtimeOpts) = mount.runtimeOptions else {
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stat the source to see if it's a file
|
||||
let fm = FileManager.default
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fm.fileExists(atPath: mount.source, isDirectory: &isDirectory) else {
|
||||
// Doesn't exist. Let the normal flow handle the error
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
if isDirectory.boolValue {
|
||||
// It's a directory, pass through unchanged
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
// It's a file, so prepare it.
|
||||
let prepared = try context.prepareFileMount(mount: mount, runtimeOptions: runtimeOpts)
|
||||
|
||||
// Only add the directory share once per unique parent directory.
|
||||
if !sharedParentTags.contains(prepared.tag) {
|
||||
sharedParentTags.insert(prepared.tag)
|
||||
// The destination here is unused. We mount the share ourselves
|
||||
// to a location under /run in mountHoldingDirectories.
|
||||
let directoryShare = Mount.share(
|
||||
source: prepared.parentDirectory.path,
|
||||
destination: "/.file-mount-holding",
|
||||
options: mount.options.filter { $0 != "bind" },
|
||||
runtimeOptions: runtimeOpts
|
||||
)
|
||||
transformed.append(directoryShare)
|
||||
}
|
||||
}
|
||||
|
||||
context.transformedMounts = transformed
|
||||
return context
|
||||
}
|
||||
|
||||
private mutating func prepareFileMount(
|
||||
mount: Mount,
|
||||
runtimeOptions: [String]
|
||||
) throws -> PreparedMount {
|
||||
let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath()
|
||||
let filename = resolvedSource.lastPathComponent
|
||||
let parentDirectory = resolvedSource.deletingLastPathComponent()
|
||||
let tag = try hashFilePath(path: parentDirectory.path)
|
||||
|
||||
let prepared = PreparedMount(
|
||||
hostFilePath: mount.source,
|
||||
containerDestination: mount.destination,
|
||||
filename: filename,
|
||||
parentDirectory: parentDirectory,
|
||||
tag: tag,
|
||||
options: mount.options,
|
||||
guestHoldingPath: nil
|
||||
)
|
||||
|
||||
preparedMounts.append(prepared)
|
||||
return prepared
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Set up the holding directory paths for all file mounts.
|
||||
/// Since virtiofs shares are now mounted once at /run/virtiofs, the holding
|
||||
/// directories appear as subdirectories there automatically.
|
||||
/// - Parameters:
|
||||
/// - vmMounts: The AttachedFilesystem array from the VM for this container
|
||||
/// - agent: The VM agent for RPCs (unused, kept for API compatibility)
|
||||
mutating func mountHoldingDirectories(
|
||||
vmMounts: [AttachedFilesystem],
|
||||
agent: any VirtualMachineAgent
|
||||
) async throws {
|
||||
for i in preparedMounts.indices {
|
||||
let prepared = preparedMounts[i]
|
||||
|
||||
// Verify the attached filesystem exists
|
||||
guard
|
||||
vmMounts.first(where: {
|
||||
$0.type == "virtiofs" && $0.source == prepared.tag
|
||||
}) != nil
|
||||
else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "could not find attached filesystem for file mount \(prepared.hostFilePath)"
|
||||
)
|
||||
}
|
||||
|
||||
// With unified virtiofs, holding directories are subdirectories under /run/virtiofs
|
||||
let guestPath = "/run/virtiofs/\(prepared.tag)"
|
||||
preparedMounts[i].guestHoldingPath = guestPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Get the bind mounts to append to the OCI spec.
|
||||
func ociBindMounts() -> [ContainerizationOCI.Mount] {
|
||||
preparedMounts.compactMap { prepared in
|
||||
guard let guestPath = prepared.guestHoldingPath else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ContainerizationOCI.Mount(
|
||||
type: "none",
|
||||
source: "\(guestPath)/\(prepared.filename)",
|
||||
destination: prepared.containerDestination,
|
||||
options: ["bind"] + prepared.options
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import GRPCCore
|
||||
import GRPCNIOTransportCore
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// Buffers incoming bytes until the full gRPC HTTP/2 pipeline is configured, then replays them.
|
||||
///
|
||||
/// This prevents the race condition where the vminitd server's initial HTTP/2 SETTINGS frame
|
||||
/// arrives and is discarded before `configureGRPCClientPipeline` has finished installing
|
||||
/// `ClientConnectionHandler`.
|
||||
///
|
||||
/// The handler is added via `ClientBootstrap.channelInitializer`, which runs before
|
||||
/// `registerAlreadyConfigured0` adds the fd to epoll/kqueue — guaranteeing it is in place
|
||||
/// before any bytes can arrive on the socket.
|
||||
///
|
||||
/// When `NIOHTTP2Handler` is added to the pipeline (inside `configureGRPCClientPipeline`), its
|
||||
/// `handlerAdded` fires an outbound flush (the HTTP/2 client preface). We intercept that flush
|
||||
/// and schedule a deferred removal via the event loop. Because `configureGRPCClientPipeline` runs
|
||||
/// as a single synchronous event loop task, the deferred removal is guaranteed to run after that
|
||||
/// entire task completes — i.e., after `ClientConnectionHandler` is also in the pipeline.
|
||||
/// Buffered bytes are replayed atomically as part of the pipeline removal.
|
||||
|
||||
// FIXME: This handler is needed until the swift GRPC libraries offers us a way to create a
|
||||
// client transport from an existing fd. Remove this type when such an API exists.
|
||||
public final class HTTP2ConnectBufferingHandler: ChannelDuplexHandler, RemovableChannelHandler {
|
||||
public typealias InboundIn = ByteBuffer
|
||||
public typealias InboundOut = ByteBuffer
|
||||
public typealias OutboundIn = ByteBuffer
|
||||
public typealias OutboundOut = ByteBuffer
|
||||
|
||||
private var removalScheduled = false
|
||||
private var bufferedReads: [NIOAny] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
bufferedReads.append(data)
|
||||
}
|
||||
|
||||
public func channelReadComplete(context: ChannelHandlerContext) {
|
||||
// Suppress while buffering; a single readComplete is emitted after replay.
|
||||
}
|
||||
|
||||
public func flush(context: ChannelHandlerContext) {
|
||||
if !removalScheduled {
|
||||
removalScheduled = true
|
||||
// Defer removal to the next event loop task. configureGRPCClientPipeline runs as a
|
||||
// single synchronous event loop task, so this deferred task is guaranteed to run
|
||||
// after that whole task completes (including ClientConnectionHandler being added).
|
||||
context.eventLoop.assumeIsolatedUnsafeUnchecked().execute {
|
||||
context.pipeline.syncOperations.removeHandler(self, promise: nil)
|
||||
}
|
||||
}
|
||||
context.flush()
|
||||
}
|
||||
|
||||
public func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) {
|
||||
var didRead = false
|
||||
while !bufferedReads.isEmpty {
|
||||
context.fireChannelRead(bufferedReads.removeFirst())
|
||||
didRead = true
|
||||
}
|
||||
if didRead {
|
||||
context.fireChannelReadComplete()
|
||||
}
|
||||
context.leavePipeline(removalToken: removalToken)
|
||||
}
|
||||
|
||||
public func channelInactive(context: ChannelHandlerContext) {
|
||||
bufferedReads.removeAll()
|
||||
context.fireChannelInactive()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import Crypto
|
||||
import Foundation
|
||||
|
||||
extension Mount {
|
||||
/// A deterministic hash of the mount's source path, used as the virtiofs tag.
|
||||
///
|
||||
/// Resolves symlinks before hashing so that different paths to the same
|
||||
/// directory produce an identical tag.
|
||||
public var tagHash: String {
|
||||
get throws {
|
||||
try hashFilePath(path: self.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hashFilePath(path: String) throws -> String {
|
||||
// Resolve symlinks so different paths to the same directory get the same hash.
|
||||
let resolvedSource = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
|
||||
guard let data = resolvedSource.data(using: .utf8) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "\(path) could not be converted to Data")
|
||||
}
|
||||
return String(SHA256.hash(data: data).encoded.prefix(36))
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// Static table lookups for a container. The values will be used to
|
||||
/// construct /etc/hosts for a given container.
|
||||
public struct Hosts: Sendable {
|
||||
/// Represents one entry in an /etc/hosts file.
|
||||
public struct Entry: Sendable {
|
||||
/// The IPV4 or IPV6 address in String form.
|
||||
public var ipAddress: String
|
||||
/// The hostname(s) for the entry.
|
||||
public var hostnames: [String]
|
||||
/// An optional comment to be placed to the right side of the entry.
|
||||
public var comment: String?
|
||||
|
||||
public init(ipAddress: String, hostnames: [String], comment: String? = nil) {
|
||||
self.comment = comment
|
||||
self.hostnames = hostnames
|
||||
self.ipAddress = ipAddress
|
||||
}
|
||||
|
||||
/// The information in the structure rendered to a String representation
|
||||
/// that matches the format /etc/hosts expects.
|
||||
public var rendered: String {
|
||||
var line = ipAddress
|
||||
if !hostnames.isEmpty {
|
||||
line += " " + hostnames.joined(separator: " ")
|
||||
}
|
||||
if let comment {
|
||||
line += " # \(comment) "
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
public static func localHostIPV4(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "127.0.0.1",
|
||||
hostnames: ["localhost"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
|
||||
public static func localHostIPV6(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "::1",
|
||||
hostnames: ["localhost", "ip6-localhost", "ip6-loopback"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
|
||||
public static func ipv6LocalNet(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "fe00::",
|
||||
hostnames: ["ip6-localnet"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
|
||||
public static func ipv6MulticastPrefix(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "ff00::",
|
||||
hostnames: ["ip6-mcastprefix"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
|
||||
public static func ipv6AllNodes(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "ff02::1",
|
||||
hostnames: ["ip6-allnodes"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
|
||||
public static func ipv6AllRouters(comment: String? = nil) -> Self {
|
||||
Self(
|
||||
ipAddress: "ff02::2",
|
||||
hostnames: ["ip6-allrouters"],
|
||||
comment: comment
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries to be written to /etc/hosts.
|
||||
public var entries: [Entry]
|
||||
|
||||
/// A comment to render at the top of the file.
|
||||
public var comment: String?
|
||||
|
||||
public init(
|
||||
entries: [Entry],
|
||||
comment: String? = nil
|
||||
) {
|
||||
self.entries = entries
|
||||
self.comment = comment
|
||||
}
|
||||
}
|
||||
|
||||
extension Hosts {
|
||||
/// A default entry that can be used for convenience. It contains a IPV4
|
||||
/// and IPV6 localhost entry, as well as ipv6 localnet, ipv6 mcastprefix,
|
||||
/// ipv6 allnodes, and ipv6 allrouters.
|
||||
public static let `default` = Hosts(entries: [
|
||||
Entry.localHostIPV4(),
|
||||
Entry.localHostIPV6(),
|
||||
Entry.ipv6LocalNet(),
|
||||
Entry.ipv6MulticastPrefix(),
|
||||
Entry.ipv6AllNodes(),
|
||||
Entry.ipv6AllRouters(),
|
||||
])
|
||||
|
||||
/// Returns a string variant of the data that can be written to
|
||||
/// /etc/hosts directly.
|
||||
public var hostsFile: String {
|
||||
var lines: [String] = []
|
||||
|
||||
if let comment {
|
||||
lines.append("# \(comment)")
|
||||
}
|
||||
|
||||
for entry in entries {
|
||||
lines.append(entry.rendered)
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A provider that manages hotplug operations for a virtual machine instance.
|
||||
///
|
||||
/// Conforming types implement the mechanics of hotplugging block devices and
|
||||
/// virtiofs shares into a running VM.
|
||||
public protocol HotplugProvider: Sendable {
|
||||
/// Hotplug a block device into the running VM.
|
||||
/// - Parameters:
|
||||
/// - block: The mount configuration for the block device
|
||||
/// - id: The container ID to associate with this device
|
||||
/// - Returns: The attached filesystem with the device path in the guest
|
||||
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem
|
||||
|
||||
/// Register mounts for a container in the VM's mount registry.
|
||||
/// - Parameters:
|
||||
/// - id: The container ID
|
||||
/// - rootfs: The rootfs attachment from hotplug
|
||||
/// - additionalMounts: Additional mounts to register
|
||||
func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws
|
||||
|
||||
/// Release a hotplug device.
|
||||
/// - Parameter id: The container ID who should be released
|
||||
func releaseHotplug(id: String) async throws
|
||||
|
||||
/// Hotplug virtiofs directories into the running VM.
|
||||
/// - Parameters:
|
||||
/// - mounts: The virtiofs mounts to add
|
||||
/// - id: The container ID that owns these mounts
|
||||
func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws
|
||||
|
||||
/// Release virtiofs shares for a container.
|
||||
/// - Parameter id: The container ID whose shares should be released
|
||||
func releaseVirtioFS(id: String) async throws
|
||||
|
||||
/// Clean up resources held by the provider.
|
||||
func cleanup()
|
||||
}
|
||||
|
||||
extension HotplugProvider {
|
||||
public func cleanup() {}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
/// A type that returns a stream of Data.
|
||||
public protocol ReaderStream: Sendable {
|
||||
func stream() -> AsyncStream<Data>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Terminal: ReaderStream {
|
||||
public func stream() -> AsyncStream<Data> {
|
||||
.init { cont in
|
||||
self.handle.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
self.handle.readabilityHandler = nil
|
||||
cont.finish()
|
||||
return
|
||||
}
|
||||
cont.yield(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Terminal: Writer {}
|
||||
@@ -0,0 +1,23 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
/// A type that writes the provided Data.
|
||||
public protocol Writer: Sendable {
|
||||
func write(_ data: Data) throws
|
||||
func close() throws
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
/// Type representing an OCI container image.
|
||||
public struct Image: Sendable {
|
||||
private let contentStore: ContentStore
|
||||
/// The description for the image that comprises of its name and a reference to its root descriptor.
|
||||
public let description: Description
|
||||
|
||||
/// A description of the OCI image.
|
||||
public struct Description: Sendable {
|
||||
/// The string reference of the image.
|
||||
public let reference: String
|
||||
/// The descriptor identifying the image.
|
||||
public let descriptor: Descriptor
|
||||
/// The digest for the image.
|
||||
public var digest: String { descriptor.digest }
|
||||
/// The media type of the image.
|
||||
public var mediaType: String { descriptor.mediaType }
|
||||
|
||||
public init(reference: String, descriptor: Descriptor) {
|
||||
self.reference = reference
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor for the image.
|
||||
public var descriptor: Descriptor { description.descriptor }
|
||||
/// The digest of the image.
|
||||
public var digest: String { description.digest }
|
||||
/// The media type of the image.
|
||||
public var mediaType: String { description.mediaType }
|
||||
/// The string reference for the image.
|
||||
public var reference: String { description.reference }
|
||||
|
||||
public init(description: Description, contentStore: ContentStore) {
|
||||
self.description = description
|
||||
self.contentStore = contentStore
|
||||
}
|
||||
|
||||
/// Returns the underlying OCI index for the image.
|
||||
public func index() async throws -> Index {
|
||||
guard let content: Content = try await contentStore.get(digest: digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns the manifest for the specified platform.
|
||||
public func manifest(for platform: Platform) async throws -> Manifest {
|
||||
let index = try await self.index()
|
||||
let desc = index.manifests.first { desc in
|
||||
desc.platform == platform
|
||||
}
|
||||
guard let desc else {
|
||||
throw ContainerizationError(.unsupported, message: "platform \(platform.description)")
|
||||
}
|
||||
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns the descriptor for the given platform. If it does not exist
|
||||
/// will throw a ContainerizationError with the code set to .invalidArgument.
|
||||
public func descriptor(for platform: Platform) async throws -> Descriptor {
|
||||
let index = try await self.index()
|
||||
let desc = index.manifests.first { $0.platform == platform }
|
||||
guard let desc else {
|
||||
throw ContainerizationError(.invalidArgument, message: "unsupported platform \(platform)")
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Returns the OCI config for the specified platform.
|
||||
public func config(for platform: Platform) async throws -> ContainerizationOCI.Image {
|
||||
let manifest = try await self.manifest(for: platform)
|
||||
let desc = manifest.config
|
||||
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns a list of digests to all the referenced OCI objects.
|
||||
public func referencedDigests() async throws -> [String] {
|
||||
var referenced: [String] = [self.digest.trimmingDigestPrefix]
|
||||
let index = try await self.index()
|
||||
for manifest in index.manifests {
|
||||
referenced.append(manifest.digest.trimmingDigestPrefix)
|
||||
guard let m: Manifest = try? await contentStore.get(digest: manifest.digest) else {
|
||||
// If the requested digest does not exist or is not a manifest. Skip.
|
||||
// It's safe to skip processing this digest as it won't have any child layers.
|
||||
continue
|
||||
}
|
||||
let descs = m.layers + [m.config]
|
||||
referenced.append(contentsOf: descs.map { $0.digest.trimmingDigestPrefix })
|
||||
}
|
||||
return referenced
|
||||
}
|
||||
|
||||
/// Returns a reference to the content blob for the image. The specified digest must be referenced by the image in one of its layers.
|
||||
public func getContent(digest: String) async throws -> Content {
|
||||
guard try await self.referencedDigests().contains(digest.trimmingDigestPrefix) else {
|
||||
throw ContainerizationError(.internalError, message: "image \(self.reference) does not reference digest \(digest)")
|
||||
}
|
||||
guard let content: Content = try await contentStore.get(digest: digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationIO
|
||||
import ContainerizationOCI
|
||||
import Crypto
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
public struct ExportOperation: Sendable {
|
||||
let name: String
|
||||
let tag: String
|
||||
let contentStore: ContentStore
|
||||
let client: ContentClient
|
||||
let progress: ProgressHandler?
|
||||
|
||||
public init(name: String, tag: String, contentStore: ContentStore, client: ContentClient, progress: ProgressHandler? = nil) {
|
||||
self.contentStore = contentStore
|
||||
self.client = client
|
||||
self.progress = progress
|
||||
self.name = name
|
||||
self.tag = tag
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func export(index: Descriptor, platforms: (Platform) -> Bool, filter: (Descriptor) -> Bool = { _ in true }) async throws -> Descriptor {
|
||||
var pushQueue: [[Descriptor]] = []
|
||||
var current: [Descriptor] = [index]
|
||||
while !current.isEmpty {
|
||||
let children = try await self.getChildren(descs: current)
|
||||
let matches = try filterPlatforms(matcher: platforms, children).uniqued { $0.digest }
|
||||
pushQueue.append(matches)
|
||||
current = matches
|
||||
}
|
||||
let localIndexData = try await self.createIndex(from: index, matching: platforms)
|
||||
|
||||
await updatePushProgress(pushQueue: pushQueue, localIndexData: localIndexData)
|
||||
|
||||
// We need to work bottom up when pushing an image.
|
||||
// First, the tar blobs / config layers, then, the manifests and so on...
|
||||
// When processing a given "level", the requests maybe made in parallel.
|
||||
// We need to ensure that the child level has been uploaded fully
|
||||
// before uploading the parent level.
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for layerGroup in pushQueue.reversed() {
|
||||
for chunk in layerGroup.chunks(ofCount: 8) {
|
||||
for desc in chunk.filter(filter) {
|
||||
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
||||
}
|
||||
group.addTask {
|
||||
let readStream = try ReadStream(url: content.path)
|
||||
try await self.pushContent(descriptor: desc, stream: readStream)
|
||||
}
|
||||
}
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lastly, we need to construct and push a new index, since we may
|
||||
// have pushed content only for specific platforms.
|
||||
let digest = SHA256.hash(data: localIndexData)
|
||||
// The descriptor's mediaType becomes the HTTP Content-Type in
|
||||
// RegistryClient.push and must match the mediaType field inside
|
||||
// localIndexData. Registries reject mismatches with MANIFEST_INVALID.
|
||||
let descriptor = Descriptor(
|
||||
mediaType: index.mediaType,
|
||||
digest: digest.digestString,
|
||||
size: Int64(localIndexData.count))
|
||||
let stream = ReadStream(data: localIndexData)
|
||||
try await self.pushContent(descriptor: descriptor, stream: stream)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func updatePushProgress(pushQueue: [[Descriptor]], localIndexData: Data) async {
|
||||
for layerGroup in pushQueue {
|
||||
for desc in layerGroup {
|
||||
await progress?([
|
||||
.addTotalSize(desc.size),
|
||||
.addTotalItems(1),
|
||||
])
|
||||
}
|
||||
}
|
||||
await progress?([
|
||||
.addTotalSize(Int64(localIndexData.count)),
|
||||
.addTotalItems(1),
|
||||
])
|
||||
}
|
||||
|
||||
private func createIndex(from index: Descriptor, matching: (Platform) -> Bool) async throws -> Data {
|
||||
guard let content = try await self.contentStore.get(digest: index.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(index.digest)")
|
||||
}
|
||||
var idx: Index = try content.decode()
|
||||
let manifests = idx.manifests
|
||||
var matchedManifests: [Descriptor] = []
|
||||
var skippedPlatforms = false
|
||||
for manifest in manifests {
|
||||
guard let p = manifest.platform else {
|
||||
continue
|
||||
}
|
||||
if matching(p) {
|
||||
matchedManifests.append(manifest)
|
||||
} else {
|
||||
skippedPlatforms = true
|
||||
}
|
||||
}
|
||||
if !skippedPlatforms {
|
||||
return try content.data()
|
||||
}
|
||||
idx.manifests = matchedManifests
|
||||
return try JSONEncoder().encode(idx)
|
||||
}
|
||||
|
||||
private func pushContent(descriptor: Descriptor, stream: ReadStream) async throws {
|
||||
do {
|
||||
let generator = {
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
try await client.push(name: name, ref: tag, descriptor: descriptor, streamGenerator: generator, progress: progress)
|
||||
await progress?([
|
||||
.addSize(descriptor.size),
|
||||
.addItems(1),
|
||||
])
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code != .exists else {
|
||||
// We reported the total items and size and have to account for them in existing content.
|
||||
await progress?([
|
||||
.addSize(descriptor.size),
|
||||
.addItems(1),
|
||||
])
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private func getChildren(descs: [Descriptor]) async throws -> [Descriptor] {
|
||||
var out: [Descriptor] = []
|
||||
for desc in descs {
|
||||
let mediaType = desc.mediaType
|
||||
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
||||
}
|
||||
switch mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
let index: Index = try content.decode()
|
||||
out.append(contentsOf: index.manifests)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let manifest: Manifest = try content.decode()
|
||||
out.append(manifest.config)
|
||||
out.append(contentsOf: manifest.layers)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
public struct ImportOperation: Sendable {
|
||||
static let decoder = JSONDecoder()
|
||||
|
||||
let client: ContentClient
|
||||
let ingestDir: URL
|
||||
let contentStore: ContentStore
|
||||
let progress: ProgressHandler?
|
||||
let name: String
|
||||
let maxConcurrentDownloads: Int
|
||||
|
||||
public init(name: String, contentStore: ContentStore, client: ContentClient, ingestDir: URL, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3) {
|
||||
self.client = client
|
||||
self.ingestDir = ingestDir
|
||||
self.contentStore = contentStore
|
||||
self.progress = progress
|
||||
self.name = name
|
||||
self.maxConcurrentDownloads = maxConcurrentDownloads
|
||||
}
|
||||
|
||||
/// Pull the required image layers for the provided descriptor and platform(s) into the given directory using the provided client. Returns a descriptor to the Index manifest.
|
||||
public func `import`(root: Descriptor, matcher: (ContainerizationOCI.Platform) -> Bool) async throws -> Descriptor {
|
||||
var toProcess = [root]
|
||||
while !toProcess.isEmpty {
|
||||
// Count the total number of blobs and their size
|
||||
if let progress {
|
||||
var size: Int64 = 0
|
||||
for desc in toProcess {
|
||||
size += desc.size
|
||||
}
|
||||
await progress([
|
||||
.addTotalSize(size),
|
||||
.addTotalItems(toProcess.count),
|
||||
])
|
||||
}
|
||||
|
||||
try await self.fetchAll(toProcess)
|
||||
let children = try await self.walk(toProcess)
|
||||
let filtered = try filterPlatforms(matcher: matcher, children)
|
||||
toProcess = filtered.uniqued { $0.digest }
|
||||
}
|
||||
|
||||
guard root.mediaType != MediaTypes.dockerManifestList && root.mediaType != MediaTypes.index else {
|
||||
return root
|
||||
}
|
||||
|
||||
// Create an index for the root descriptor and write it to the content store
|
||||
let index = try await self.createIndex(for: root)
|
||||
// In cases where the root descriptor pointed to `MediaTypes.imageManifest`
|
||||
// Or `MediaTypes.dockerManifest`, it is required that we check the supported platform
|
||||
// matches the platforms we were asked to pull. This can be done only after we created
|
||||
// the Index.
|
||||
let supportedPlatforms = index.manifests.compactMap { $0.platform }
|
||||
guard supportedPlatforms.allSatisfy(matcher) else {
|
||||
throw ContainerizationError(.unsupported, message: "image \(root.digest) does not support required platforms")
|
||||
}
|
||||
let writer = try ContentWriter(for: self.ingestDir)
|
||||
let result = try writer.create(from: index)
|
||||
return Descriptor(
|
||||
mediaType: MediaTypes.index,
|
||||
digest: result.digest.digestString,
|
||||
size: Int64(result.size))
|
||||
}
|
||||
|
||||
private func getManifestContent<T: Sendable & Codable>(descriptor: Descriptor) async throws -> T {
|
||||
do {
|
||||
if let content = try await self.contentStore.get(digest: descriptor.digest.trimmingDigestPrefix) {
|
||||
return try content.decode()
|
||||
}
|
||||
if let content = try? LocalContent(path: ingestDir.appending(path: descriptor.digest.trimmingDigestPrefix)) {
|
||||
return try content.decode()
|
||||
}
|
||||
return try await self.client.fetch(name: name, descriptor: descriptor)
|
||||
} catch {
|
||||
throw ContainerizationError(.internalError, message: "cannot fetch content with digest \(descriptor.digest)", cause: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func walk(_ descriptors: [Descriptor]) async throws -> [Descriptor] {
|
||||
var out: [Descriptor] = []
|
||||
for desc in descriptors {
|
||||
let mediaType = desc.mediaType
|
||||
switch mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
let index: Index = try await self.getManifestContent(descriptor: desc)
|
||||
out.append(contentsOf: index.manifests)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let manifest: Manifest = try await self.getManifestContent(descriptor: desc)
|
||||
out.append(manifest.config)
|
||||
out.append(contentsOf: manifest.layers)
|
||||
default:
|
||||
// TODO: Explicitly handle other content types
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func fetchAll(_ descriptors: [Descriptor]) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var iterator = descriptors.makeIterator()
|
||||
// Start initial batch of concurrent downloads based on maxConcurrentDownloads
|
||||
for _ in 0..<self.maxConcurrentDownloads {
|
||||
if let desc = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.fetch(desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
// As tasks complete, add new ones to maintain concurrency
|
||||
for try await _ in group {
|
||||
if let desc = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.fetch(desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(_ descriptor: Descriptor) async throws {
|
||||
if let found = try await self.contentStore.get(digest: descriptor.digest) {
|
||||
try FileManager.default.copyItem(at: found.path, to: ingestDir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix))
|
||||
await progress?([
|
||||
// Count the size of the blob
|
||||
.addSize(descriptor.size),
|
||||
// Count the number of blobs
|
||||
.addItems(1),
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if descriptor.size > 1.mib() {
|
||||
try await self.fetchBlob(descriptor)
|
||||
} else {
|
||||
try await self.fetchData(descriptor)
|
||||
}
|
||||
// Count the number of blobs
|
||||
await progress?([
|
||||
.addItems(1)
|
||||
])
|
||||
}
|
||||
|
||||
private func fetchBlob(_ descriptor: Descriptor) async throws {
|
||||
let id = UUID().uuidString
|
||||
let fm = FileManager.default
|
||||
let tempFile = ingestDir.appendingPathComponent(id)
|
||||
let (_, digest) = try await client.fetchBlob(name: name, descriptor: descriptor, into: tempFile, progress: progress)
|
||||
guard digest.digestString == descriptor.digest else {
|
||||
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(digest.digestString)")
|
||||
}
|
||||
do {
|
||||
try fm.moveItem(at: tempFile, to: ingestDir.appendingPathComponent(digest.encoded))
|
||||
} catch let err as NSError {
|
||||
guard err.code == NSFileWriteFileExistsError else {
|
||||
throw err
|
||||
}
|
||||
try fm.removeItem(at: tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func fetchData(_ descriptor: Descriptor) async throws -> Data {
|
||||
let data = try await client.fetchData(name: name, descriptor: descriptor)
|
||||
let writer = try ContentWriter(for: ingestDir)
|
||||
let result = try writer.write(data)
|
||||
if let progress {
|
||||
let size = Int64(result.size)
|
||||
await progress([
|
||||
.addSize(size)
|
||||
])
|
||||
}
|
||||
guard result.digest.digestString == descriptor.digest else {
|
||||
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(result.digest.digestString)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func createIndex(for root: Descriptor) async throws -> Index {
|
||||
switch root.mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
return try await self.getManifestContent(descriptor: root)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let supportedPlatforms = try await getSupportedPlatforms(for: root)
|
||||
guard supportedPlatforms.count == 1 else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message:
|
||||
"descriptor \(root.mediaType) with digest \(root.digest) does not list any supported platform or supports more than one platform, supported platforms: \(supportedPlatforms)"
|
||||
)
|
||||
}
|
||||
let platform = supportedPlatforms.first!
|
||||
var root = root
|
||||
root.platform = platform
|
||||
let index = ContainerizationOCI.Index(
|
||||
schemaVersion: 2, manifests: [root],
|
||||
annotations: [
|
||||
// indicate that this is a synthesized index which is not directly user facing
|
||||
AnnotationKeys.containerizationIndexIndirect: "true"
|
||||
])
|
||||
return index
|
||||
default:
|
||||
throw ContainerizationError(.internalError, message: "failed to create index for descriptor \(root.digest), media type \(root.mediaType)")
|
||||
}
|
||||
}
|
||||
|
||||
private func getSupportedPlatforms(for root: Descriptor) async throws -> [ContainerizationOCI.Platform] {
|
||||
var supportedPlatforms: [ContainerizationOCI.Platform] = []
|
||||
var toProcess = [root]
|
||||
while !toProcess.isEmpty {
|
||||
let children = try await self.walk(toProcess)
|
||||
for child in children {
|
||||
if let p = child.platform {
|
||||
supportedPlatforms.append(p)
|
||||
continue
|
||||
}
|
||||
switch child.mediaType {
|
||||
case MediaTypes.imageConfig, MediaTypes.dockerImageConfig:
|
||||
let config: ContainerizationOCI.Image = try await self.getManifestContent(descriptor: child)
|
||||
let p = ContainerizationOCI.Platform(
|
||||
arch: config.architecture, os: config.os, osFeatures: config.osFeatures, variant: config.variant
|
||||
)
|
||||
supportedPlatforms.append(p)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
toProcess = children
|
||||
}
|
||||
return supportedPlatforms
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
/// Exports the specified images and their associated layers to an OCI Image Layout directory.
|
||||
/// This function saves the images identified by the `references` array, including their
|
||||
/// manifests and layer blobs, into a directory structure compliant with the OCI Image Layout specification at the given `out` URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - references: A list image references that exists in the `ImageStore` that are to be saved in the OCI Image Layout format.
|
||||
/// - out: A URL to a directory on disk at which the OCI Image Layout structure will be created.
|
||||
/// - platform: An optional parameter to indicate the platform to be saved for the images.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the images will be saved.
|
||||
///
|
||||
public func save(references: [String], out: URL, platform: Platform? = nil) async throws {
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.uniqueTemporaryDirectory()
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
var toSave: [Image] = []
|
||||
for reference in references {
|
||||
let image = try await self.get(reference: reference)
|
||||
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
|
||||
guard allowedMediaTypes.contains(image.mediaType) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot save image \(image.reference) with Index media type \(image.mediaType)")
|
||||
}
|
||||
toSave.append(image)
|
||||
}
|
||||
let client = try LocalOCILayoutClient(root: out)
|
||||
var saved: [Descriptor] = []
|
||||
|
||||
for image in toSave {
|
||||
let ref = try Reference.parse(image.reference)
|
||||
let name = ref.path
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(image.reference)")
|
||||
}
|
||||
let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: nil)
|
||||
var descriptor = try await operation.export(index: image.descriptor, platforms: matcher)
|
||||
client.setImageReferenceAnnotation(descriptor: &descriptor, reference: image.reference)
|
||||
saved.append(descriptor)
|
||||
}
|
||||
try client.createOCILayoutStructure(directory: out, manifests: saved)
|
||||
}
|
||||
|
||||
/// Imports one or more images and their associated layers from an OCI Image Layout directory.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - directory: A URL to a directory on disk at that follows the OCI Image Layout structure.
|
||||
/// - progress: An optional handler over which progress update events about the load operation can be received.
|
||||
/// - Returns: The list of images that were loaded into the `ImageStore`.
|
||||
///
|
||||
public func load(from directory: URL, progress: ProgressHandler? = nil) async throws -> [Image] {
|
||||
let client = try LocalOCILayoutClient(root: directory)
|
||||
let index = try client.loadIndexFromOCILayout(directory: directory)
|
||||
let matcher = createPlatformMatcher(for: nil)
|
||||
|
||||
var loaded: [Image.Description] = []
|
||||
let (id, tempDir) = try await self.contentStore.newIngestSession()
|
||||
do {
|
||||
for descriptor in index.manifests {
|
||||
let reference = client.getImageReferencefromDescriptor(descriptor: descriptor)
|
||||
let ref = try Reference.parse(reference)
|
||||
let name = ref.path
|
||||
let operation = ImportOperation(name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress)
|
||||
let indexDesc = try await operation.import(root: descriptor, matcher: matcher)
|
||||
loaded.append(Image.Description(reference: reference, descriptor: indexDesc))
|
||||
}
|
||||
|
||||
let loadedImages = loaded
|
||||
let importedImages = try await self.lock.withLock { lock in
|
||||
var images: [Image] = []
|
||||
try await self.contentStore.completeIngestSession(id)
|
||||
for description in loadedImages {
|
||||
let img = try await self._create(description: description, lock: lock)
|
||||
images.append(img)
|
||||
}
|
||||
return images
|
||||
}
|
||||
guard importedImages.count > 0 else {
|
||||
throw ContainerizationError(.internalError, message: "failed to import image")
|
||||
}
|
||||
return importedImages
|
||||
} catch {
|
||||
try? await self.contentStore.cancelIngestSession(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
/// A ReferenceManager handles the mappings between an image's
|
||||
/// reference and the underlying descriptor inside of a content store.
|
||||
internal actor ReferenceManager {
|
||||
private let path: URL
|
||||
|
||||
private typealias State = [String: Descriptor]
|
||||
private var images: State
|
||||
|
||||
public init(path: URL) throws {
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
|
||||
self.path = path
|
||||
self.images = [:]
|
||||
}
|
||||
|
||||
private func load() throws -> State {
|
||||
let statePath = self.path.appendingPathComponent("state.json")
|
||||
guard FileManager.default.fileExists(atPath: statePath.absolutePath()) else {
|
||||
return [:]
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: statePath)
|
||||
return try JSONDecoder().decode(State.self, from: data)
|
||||
} catch {
|
||||
throw ContainerizationError(.internalError, message: "failed to load image state \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ state: State) throws {
|
||||
let statePath = self.path.appendingPathComponent("state.json")
|
||||
try JSONEncoder().encode(state).write(to: statePath)
|
||||
}
|
||||
|
||||
public func delete(reference: String) throws {
|
||||
var state = try self.load()
|
||||
state.removeValue(forKey: reference)
|
||||
try self.save(state)
|
||||
}
|
||||
|
||||
public func delete(image: Image.Description) throws {
|
||||
try self.delete(reference: image.reference)
|
||||
}
|
||||
|
||||
public func create(description: Image.Description) throws {
|
||||
var state = try self.load()
|
||||
state[description.reference] = description.descriptor
|
||||
try self.save(state)
|
||||
}
|
||||
|
||||
public func list() throws -> [Image.Description] {
|
||||
let state = try self.load()
|
||||
return state.map { key, val in
|
||||
let description = Image.Description(reference: key, descriptor: val)
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
public func get(reference: String) throws -> Image.Description {
|
||||
let images = try self.list()
|
||||
let hit = images.first(where: { image in
|
||||
image.reference == reference
|
||||
})
|
||||
guard let hit else {
|
||||
throw ContainerizationError(.notFound, message: "image \(reference) not found")
|
||||
}
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// An ImageStore handles the mappings between an image's
|
||||
/// reference and the underlying descriptor inside of a content store.
|
||||
public actor ImageStore: Sendable {
|
||||
/// The ImageStore path it was created with.
|
||||
public nonisolated let path: URL
|
||||
|
||||
private let referenceManager: ReferenceManager
|
||||
internal let contentStore: ContentStore
|
||||
internal let lock: AsyncLock = AsyncLock()
|
||||
|
||||
public init(path: URL, contentStore: ContentStore? = nil) throws {
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
|
||||
if let contentStore {
|
||||
self.contentStore = contentStore
|
||||
} else {
|
||||
self.contentStore = try LocalContentStore(path: path.appendingPathComponent("content"))
|
||||
}
|
||||
|
||||
self.path = path
|
||||
self.referenceManager = try ReferenceManager(path: path)
|
||||
}
|
||||
|
||||
/// Return the default image store for the current user.
|
||||
public static let `default`: ImageStore = {
|
||||
do {
|
||||
let root = try defaultRoot()
|
||||
return try ImageStore(path: root)
|
||||
} catch {
|
||||
fatalError("unable to initialize default ImageStore \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
private static func defaultRoot() throws -> URL {
|
||||
let root = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first
|
||||
guard let root else {
|
||||
throw ContainerizationError(.notFound, message: "unable to get Application Support directory for current user")
|
||||
}
|
||||
return root.appendingPathComponent("com.apple.containerization")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Get an image from the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: Name of the image.
|
||||
/// - pull: Pull the image if it is not found.
|
||||
///
|
||||
/// - Returns: A `Containerization.Image` object whose `reference` matches the given string.
|
||||
/// This method throws a `ContainerizationError(code: .notFound)` if the provided reference does not exist in the `ImageStore`.
|
||||
public func get(reference: String, pull: Bool = false) async throws -> Image {
|
||||
do {
|
||||
let desc = try await self.referenceManager.get(reference: reference)
|
||||
return Image(description: desc, contentStore: self.contentStore)
|
||||
} catch let error as ContainerizationError {
|
||||
if error.code == .notFound && pull {
|
||||
return try await self.pull(reference: reference)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of all images in the `ImageStore`.
|
||||
///
|
||||
/// - Returns: A `[Containerization.Image]` for all the images in the `ImageStore`.
|
||||
public func list() async throws -> [Image] {
|
||||
try await self.referenceManager.list().map { desc in
|
||||
Image(description: desc, contentStore: self.contentStore)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new image in the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - description: The underlying `Image.Description` that contains information about the reference and index descriptor for the image to be created.
|
||||
///
|
||||
/// - Note: It is assumed that the underlying manifests and blob layers for the image already exists in the `ContentStore` that the `ImageStore` was initialized with. This method is invoked when the `pull(...)` , `load(...)` and `tag(...)` methods are used.
|
||||
/// - Returns: A `Containerization.Image`
|
||||
@discardableResult
|
||||
public func create(description: Image.Description) async throws -> Image {
|
||||
try await self.lock.withLock { ctx in
|
||||
try await self._create(description: description, lock: ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
internal func _create(description: Image.Description, lock: AsyncLock.Context) async throws -> Image {
|
||||
try await self.referenceManager.create(description: description)
|
||||
return Image(description: description, contentStore: self.contentStore)
|
||||
}
|
||||
|
||||
/// Delete an image from the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: Name of the image that is to be deleted.
|
||||
/// - performCleanup: Perform a garbage collection on the `ContentStore`, removing all unreferenced image layers and manifests,
|
||||
public func delete(reference: String, performCleanup: Bool = false) async throws {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self.referenceManager.delete(reference: reference)
|
||||
if performCleanup {
|
||||
try await self._cleanUpOrphanedBlobs(lockCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up orphaned blobs that are no longer referenced by any image.
|
||||
///
|
||||
/// - Returns: Returns a tuple of `(deleted, freed)`.
|
||||
/// `deleted` : A list of the names of the content items that were deleted from the `ContentStore`,
|
||||
/// `freed` : The total size of the items that were deleted.
|
||||
@discardableResult
|
||||
public func cleanUpOrphanedBlobs() async throws -> (deleted: [String], freed: UInt64) {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self._cleanUpOrphanedBlobs(lockCtx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the size of orphaned blobs without deleting them.
|
||||
///
|
||||
/// - Returns: The total size in bytes of blobs that are not referenced by any image.
|
||||
public func calculateOrphanedBlobsSize() async throws -> UInt64 {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self._calculateOrphanedBlobsSize(lockCtx)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func _cleanUpOrphanedBlobs(_ lock: AsyncLock.Context) async throws -> (deleted: [String], freed: UInt64) {
|
||||
let images = try await self.list()
|
||||
var referenced: [String] = []
|
||||
for image in images {
|
||||
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
|
||||
}
|
||||
let (deleted, size) = try await self.contentStore.delete(keeping: referenced)
|
||||
return (deleted, size)
|
||||
}
|
||||
|
||||
private func _calculateOrphanedBlobsSize(_ lock: AsyncLock.Context) async throws -> UInt64 {
|
||||
let images = try await self.list()
|
||||
var referenced: [String] = []
|
||||
for image in images {
|
||||
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
|
||||
}
|
||||
|
||||
// Calculate size of blobs not in the referenced list
|
||||
let referencedSet = Set(referenced.map { $0.trimmingDigestPrefix })
|
||||
let blobsPath = self.path.appendingPathComponent("content/blobs/sha256")
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let allBlobs = try fileManager.contentsOfDirectory(
|
||||
at: blobsPath,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
|
||||
var orphanedSize: UInt64 = 0
|
||||
for blobURL in allBlobs {
|
||||
let digest = blobURL.lastPathComponent
|
||||
if !referencedSet.contains(digest) {
|
||||
if let resourceValues = try? blobURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let size = resourceValues.fileSize
|
||||
{
|
||||
orphanedSize += UInt64(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphanedSize
|
||||
}
|
||||
|
||||
/// Tag an existing image such that it can be referenced by another name.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - existing: The reference to an image that already exists in the `ImageStore`.
|
||||
/// - new: The new reference by which the image should also be referenced as.
|
||||
/// - Note: The new image created in the `ImageStore` will have the same `Image.Description`
|
||||
/// as that of the image with reference `existing.`
|
||||
/// - Returns: A `Containerization.Image` object to the newly created image.
|
||||
public func tag(existing: String, new: String) async throws -> Image {
|
||||
let old = try await self.get(reference: existing)
|
||||
let descriptor = old.descriptor
|
||||
do {
|
||||
_ = try Reference.parse(new)
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid reference \(new), error: \(error)")
|
||||
}
|
||||
let newDescription = Image.Description(reference: new, descriptor: descriptor)
|
||||
return try await self.create(description: newDescription)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Pull an image and its associated manifest and blob layers from a remote registry.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: A string that references an image in a remote registry of the form `<host>[:<port>]/repository:<tag>`
|
||||
/// For example: "docker.io/library/alpine:latest".
|
||||
/// - platform: An optional parameter to indicate the platform to be pulled for the image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pulled.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - progress: An optional handler over which progress update events about the pull operation can be received.
|
||||
///
|
||||
/// - Returns: A `Containerization.Image` object to the newly pulled image.
|
||||
public func pull(
|
||||
reference: String, platform: Platform? = nil, insecure: Bool = false,
|
||||
auth: Authentication? = nil, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3
|
||||
) async throws -> Image {
|
||||
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
|
||||
let ref = try Reference.parse(reference)
|
||||
let name = ref.path
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
|
||||
}
|
||||
|
||||
let rootDescriptor = try await client.resolve(name: name, tag: tag)
|
||||
let (id, tempDir) = try await self.contentStore.newIngestSession()
|
||||
let operation = ImportOperation(
|
||||
name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress, maxConcurrentDownloads: maxConcurrentDownloads)
|
||||
do {
|
||||
let index = try await operation.import(root: rootDescriptor, matcher: matcher)
|
||||
return try await self.lock.withLock { lock in
|
||||
try await self.contentStore.completeIngestSession(id)
|
||||
let description = Image.Description(reference: reference, descriptor: index)
|
||||
let image = try await self._create(description: description, lock: lock)
|
||||
return image
|
||||
}
|
||||
} catch {
|
||||
try? await self.contentStore.cancelIngestSession(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an image and its associated manifest and blob layers to a remote registry.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: A string that references an image in the `ImageStore`. It must be of the form `<host>[:<port>]/repository:<tag>`
|
||||
/// For example: "ghcr.io/foo-bar-baz/image:v1".
|
||||
/// - platform: An optional parameter to indicate the platform to be pushed for the image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pushed to the remote registry.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - progress: An optional handler over which progress update events about the push operation can be received.
|
||||
///
|
||||
public func push(reference: String, platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws {
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
|
||||
}
|
||||
|
||||
/// Push multiple image references to a remote registry, sharing a single ``RegistryClient``.
|
||||
///
|
||||
/// All references must resolve to the same registry host. Passing references that target
|
||||
/// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - references: An array of fully qualified image reference strings to push.
|
||||
/// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`).
|
||||
/// - platform: An optional parameter to indicate the platform to be pushed for each image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms will be pushed.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3.
|
||||
/// - progress: An optional handler over which progress update events about the push operations can be received.
|
||||
///
|
||||
public func push(
|
||||
references: [String], platform: Platform? = nil, insecure: Bool = false,
|
||||
auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil
|
||||
) async throws {
|
||||
guard let firstReference = references.first else {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse all references upfront: validate hosts and avoid re-parsing inside tasks.
|
||||
let parsed = try references.map { ref in try Reference.parse(ref) }
|
||||
let hosts = parsed.compactMap { $0.resolvedDomain }
|
||||
guard hosts.count == references.count else {
|
||||
throw ContainerizationError(.invalidArgument, message: "all references must include a host")
|
||||
}
|
||||
let uniqueHosts = Set(hosts)
|
||||
guard uniqueHosts.count == 1 else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "all references must target the same registry host, got: \(uniqueHosts.sorted().joined(separator: ", "))")
|
||||
}
|
||||
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(
|
||||
reference: firstReference, insecure: insecure, auth: auth,
|
||||
tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
|
||||
let pushOne: @Sendable (String) async -> (String, String?) = { reference in
|
||||
do {
|
||||
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
|
||||
return (reference, nil)
|
||||
} catch {
|
||||
return (reference, String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
var iterator = references.makeIterator()
|
||||
var failures: [(reference: String, message: String)] = []
|
||||
|
||||
await withTaskGroup(of: (String, String?).self) { group in
|
||||
for _ in 0..<maxConcurrentUploads {
|
||||
guard let reference = iterator.next() else { break }
|
||||
group.addTask { await pushOne(reference) }
|
||||
}
|
||||
for await (ref, error) in group {
|
||||
if let error {
|
||||
failures.append((ref, error))
|
||||
}
|
||||
if let reference = iterator.next() {
|
||||
group.addTask { await pushOne(reference) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.isEmpty {
|
||||
let details = failures.map { "\($0.reference): \($0.message)" }.joined(separator: "\n")
|
||||
throw ContainerizationError(.internalError, message: "failed to push one or more images:\n\(details)")
|
||||
}
|
||||
}
|
||||
|
||||
private func pushSingle(
|
||||
reference: String, client: ContentClient, matcher: @Sendable (Platform) -> Bool, progress: ProgressHandler?
|
||||
) async throws {
|
||||
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
|
||||
let img = try await self.get(reference: reference)
|
||||
guard allowedMediaTypes.contains(img.mediaType) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot push image \(reference): unsupported media type \(img.mediaType), expected an index or manifest list")
|
||||
}
|
||||
let ref = try Reference.parse(reference)
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
|
||||
}
|
||||
let operation = ExportOperation(name: ref.path, tag: tag, contentStore: self.contentStore, client: client, progress: progress)
|
||||
try await operation.export(index: img.descriptor, platforms: matcher)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Get the image for the init block from the image store.
|
||||
/// If the image does not exist locally, pull the image.
|
||||
public func getInitImage(reference: String, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws -> InitImage {
|
||||
do {
|
||||
let image = try await self.get(reference: reference)
|
||||
return InitImage(image: image)
|
||||
} catch let error as ContainerizationError {
|
||||
if error.code == .notFound {
|
||||
let image = try await self.pull(reference: reference, auth: auth, progress: progress)
|
||||
return InitImage(image: image)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// Data representing the image to use as the root filesystem for a virtual machine.
|
||||
/// Typically this image would contain the guest agent used to facilitate container
|
||||
/// workloads, as well as any extras that may be useful to have in the guest.
|
||||
public struct InitImage: Sendable {
|
||||
public var name: String { image.reference }
|
||||
|
||||
let image: Image
|
||||
|
||||
public init(image: Image) {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
|
||||
extension InitImage {
|
||||
/// Unpack the initial filesystem for the desired platform at a given path.
|
||||
public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount {
|
||||
let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib())
|
||||
var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at)
|
||||
fs.options = ["ro"]
|
||||
return fs
|
||||
}
|
||||
|
||||
/// Create a new InitImage with the reference as the name.
|
||||
/// The `rootfs` parameter must be a tar.gz file whose contents make up the filesystem for the image.
|
||||
public static func create(
|
||||
reference: String, rootfs: URL, platform: Platform,
|
||||
labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore
|
||||
) async throws -> InitImage {
|
||||
|
||||
let indexDescriptorStore = AsyncStore<Descriptor>()
|
||||
try await contentStore.ingest { dir in
|
||||
let writer = try ContentWriter(for: dir)
|
||||
var result = try writer.create(from: rootfs)
|
||||
let layerDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageLayerGzip, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
// TODO: compute and fill in the correct diffID for the above layer
|
||||
// We currently put in the sha of the fully compressed layer, this needs to be replaced with
|
||||
// the sha of the uncompressed layer.
|
||||
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
|
||||
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
|
||||
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
|
||||
result = try writer.create(from: imageConfig)
|
||||
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
|
||||
result = try writer.create(from: manifest)
|
||||
let manifestDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
|
||||
|
||||
let index = ContainerizationOCI.Index(manifests: [manifestDescriptor])
|
||||
result = try writer.create(from: index)
|
||||
|
||||
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
|
||||
await indexDescriptorStore.set(indexDescriptor)
|
||||
|
||||
}
|
||||
|
||||
guard let indexDescriptor = await indexDescriptorStore.get() else {
|
||||
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
|
||||
}
|
||||
|
||||
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
|
||||
let image = try await imageStore.create(description: description)
|
||||
return InitImage(image: image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// A multi-arch kernel image represented by an OCI image.
|
||||
public struct KernelImage: Sendable {
|
||||
/// The media type for a kernel image.
|
||||
public static let mediaType = "application/vnd.apple.containerization.kernel"
|
||||
|
||||
/// The name or reference of the image.
|
||||
public var name: String { image.reference }
|
||||
|
||||
let image: Image
|
||||
|
||||
public init(image: Image) {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
|
||||
extension KernelImage {
|
||||
/// Return the kernel from a multi arch image for a specific system platform.
|
||||
public func kernel(for platform: SystemPlatform) async throws -> Kernel {
|
||||
let manifest = try await image.manifest(for: platform.ociPlatform())
|
||||
guard let descriptor = manifest.layers.first, descriptor.mediaType == Self.mediaType else {
|
||||
throw ContainerizationError(.notFound, message: "kernel descriptor for \(platform) not found")
|
||||
}
|
||||
let content = try await image.getContent(digest: descriptor.digest)
|
||||
return Kernel(
|
||||
path: content.path,
|
||||
platform: platform
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new kernel image with the reference as the name.
|
||||
/// This will create a multi arch image containing kernel's for each provided architecture.
|
||||
public static func create(reference: String, binaries: [Kernel], labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore) async throws -> KernelImage
|
||||
{
|
||||
let indexDescriptorStore = AsyncStore<Descriptor>()
|
||||
try await contentStore.ingest { ingestPath in
|
||||
var descriptors = [Descriptor]()
|
||||
let writer = try ContentWriter(for: ingestPath)
|
||||
|
||||
for kernel in binaries {
|
||||
var result = try writer.create(from: kernel.path)
|
||||
let platform = kernel.platform.ociPlatform()
|
||||
let layerDescriptor = Descriptor(
|
||||
mediaType: mediaType,
|
||||
digest: result.digest.digestString,
|
||||
size: result.size,
|
||||
platform: platform)
|
||||
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
|
||||
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
|
||||
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
|
||||
|
||||
result = try writer.create(from: imageConfig)
|
||||
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
|
||||
result = try writer.create(from: manifest)
|
||||
let manifestDescriptor = Descriptor(
|
||||
mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
|
||||
descriptors.append(manifestDescriptor)
|
||||
}
|
||||
let index = ContainerizationOCI.Index(manifests: descriptors)
|
||||
let result = try writer.create(from: index)
|
||||
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
|
||||
await indexDescriptorStore.set(indexDescriptor)
|
||||
}
|
||||
|
||||
guard let indexDescriptor = await indexDescriptorStore.get() else {
|
||||
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
|
||||
}
|
||||
|
||||
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
|
||||
let image = try await imageStore.create(description: description)
|
||||
return KernelImage(image: image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationEXT4
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
public struct EXT4Unpacker: Unpacker {
|
||||
let blockSizeInBytes: UInt64
|
||||
|
||||
public init(blockSizeInBytes: UInt64) {
|
||||
self.blockSizeInBytes = blockSizeInBytes
|
||||
}
|
||||
|
||||
/// Performs the unpacking of a tar archive into a filesystem.
|
||||
/// - Parameters:
|
||||
/// - archive: The archive to unpack.
|
||||
/// - compression: The compression to use when unpacking the image.
|
||||
/// - path: The path to the filesystem that will be created.
|
||||
public func unpack(
|
||||
archive: URL,
|
||||
compression: ContainerizationArchive.Filter,
|
||||
at path: URL
|
||||
) async throws {
|
||||
let cleanedPath = try prepareUnpackPath(path: path)
|
||||
let filesystem = try EXT4.Formatter(
|
||||
FilePath(cleanedPath),
|
||||
minDiskSize: blockSizeInBytes
|
||||
)
|
||||
defer { try? filesystem.close() }
|
||||
|
||||
try await filesystem.unpack(
|
||||
source: archive,
|
||||
format: .paxRestricted,
|
||||
compression: compression
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a `Mount` point after unpacking the image into a filesystem.
|
||||
/// - Parameters:
|
||||
/// - image: The image to unpack.
|
||||
/// - platform: The platform content to unpack.
|
||||
/// - path: The path to the directory where the filesystem will be created.
|
||||
/// - progress: The progress handler to invoke as the unpacking progresses.
|
||||
public func unpack(
|
||||
_ image: Image,
|
||||
for platform: Platform,
|
||||
at path: URL,
|
||||
progress: ProgressHandler? = nil
|
||||
) async throws -> Mount {
|
||||
let cleanedPath = try prepareUnpackPath(path: path)
|
||||
let manifest = try await image.manifest(for: platform)
|
||||
let filesystem = try EXT4.Formatter(
|
||||
FilePath(
|
||||
cleanedPath
|
||||
),
|
||||
minDiskSize: blockSizeInBytes
|
||||
)
|
||||
defer { try? filesystem.close() }
|
||||
|
||||
// Resolve layer paths upfront. When progress reporting is enabled and a layer
|
||||
// uses zstd, decompress once so both the size-scanning pass and the unpack
|
||||
// pass share the same decompressed file.
|
||||
var resolvedLayers: [(file: URL, filter: ContainerizationArchive.Filter)] = []
|
||||
var decompressedFiles: [URL] = []
|
||||
for layer in manifest.layers {
|
||||
try Task.checkCancellation()
|
||||
let content = try await image.getContent(digest: layer.digest)
|
||||
let compression = try compressionFilter(for: layer.mediaType)
|
||||
if progress != nil && compression == .zstd {
|
||||
let decompressed = try ArchiveReader.decompressZstd(content.path)
|
||||
decompressedFiles.append(decompressed)
|
||||
resolvedLayers.append((file: decompressed, filter: .none))
|
||||
} else {
|
||||
resolvedLayers.append((file: content.path, filter: compression))
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for file in decompressedFiles {
|
||||
ArchiveReader.cleanUpDecompressedZstd(file)
|
||||
}
|
||||
}
|
||||
|
||||
if let progress {
|
||||
var totalSize: Int64 = 0
|
||||
var totalItems: Int = 0
|
||||
for layer in resolvedLayers {
|
||||
try Task.checkCancellation()
|
||||
let totals = try EXT4.Formatter.scanArchiveHeaders(
|
||||
format: .paxRestricted, filter: layer.filter, file: layer.file)
|
||||
totalSize += totals.size
|
||||
totalItems += totals.items
|
||||
}
|
||||
var totalEvents: [ProgressEvent] = []
|
||||
if totalSize > 0 {
|
||||
totalEvents.append(.addTotalSize(totalSize))
|
||||
}
|
||||
if totalItems > 0 {
|
||||
totalEvents.append(.addTotalItems(totalItems))
|
||||
}
|
||||
if !totalEvents.isEmpty {
|
||||
await progress(totalEvents)
|
||||
}
|
||||
}
|
||||
|
||||
for resolved in resolvedLayers {
|
||||
try Task.checkCancellation()
|
||||
let reader = try ArchiveReader(
|
||||
format: .paxRestricted,
|
||||
filter: resolved.filter,
|
||||
file: resolved.file
|
||||
)
|
||||
try await filesystem.unpack(reader: reader, progress: progress)
|
||||
}
|
||||
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: cleanedPath,
|
||||
destination: "/",
|
||||
options: []
|
||||
)
|
||||
}
|
||||
|
||||
private func prepareUnpackPath(path: URL) throws -> String {
|
||||
let blockPath = path.absolutePath()
|
||||
guard !FileManager.default.fileExists(atPath: blockPath) else {
|
||||
throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)")
|
||||
}
|
||||
return blockPath
|
||||
}
|
||||
|
||||
private func compressionFilter(for mediaType: String) throws -> ContainerizationArchive.Filter {
|
||||
switch mediaType {
|
||||
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
|
||||
return .none
|
||||
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
|
||||
return .gzip
|
||||
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
|
||||
return .zstd
|
||||
default:
|
||||
throw ContainerizationError(.unsupported, message: "media type \(mediaType) not supported.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// The `Unpacker` protocol defines a standardized interface that involves
|
||||
/// decompressing, extracting image layers and preparing it for use.
|
||||
///
|
||||
/// The `Unpacker` is responsible for managing the lifecycle of the
|
||||
/// unpacking process, including any temporary files or resources, until the
|
||||
/// `Mount` object is produced.
|
||||
public protocol Unpacker {
|
||||
|
||||
/// Unpacks the provided image to a specified path for a given platform.
|
||||
///
|
||||
/// This asynchronous method should handle the entire unpacking process, from reading
|
||||
/// the `Image` layers for the given `Platform` via its `Manifest`,
|
||||
/// to making the extracted contents available as a `Mount`.
|
||||
/// Implementations of this method may apply platform-specific optimizations
|
||||
/// or transformations during the unpacking.
|
||||
///
|
||||
/// Progress updates can be observed via the optional `progress` handler.
|
||||
func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler?) async throws -> Mount
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationExtras
|
||||
|
||||
/// A network interface.
|
||||
public protocol Interface: Sendable {
|
||||
/// The interface IPv4 address and subnet prefix length, as a CIDR address.
|
||||
/// Example: `192.168.64.3/24`
|
||||
var ipv4Address: CIDRv4 { get }
|
||||
|
||||
/// The IPv4 gateway address for the default route, or nil for no IPv4 default route.
|
||||
var ipv4Gateway: IPv4Address? { get }
|
||||
|
||||
/// The interface IPv6 address and subnet prefix length, as a CIDRv6 address, or nil for no IPv6 address.
|
||||
/// Example: `fd00::1/64`
|
||||
var ipv6Address: CIDRv6? { get }
|
||||
|
||||
/// The IPv6 gateway address for the default route, or nil for no IPv6 default route.
|
||||
var ipv6Gateway: IPv6Address? { get }
|
||||
|
||||
/// The interface MAC address, or nil to auto-configure the address.
|
||||
var macAddress: MACAddress? { get }
|
||||
|
||||
/// The interface MTU (Maximum Transmission Unit).
|
||||
var mtu: UInt32 { get }
|
||||
}
|
||||
|
||||
extension Interface {
|
||||
public var mtu: UInt32 { 1500 }
|
||||
public var ipv6Address: CIDRv6? { nil }
|
||||
public var ipv6Gateway: IPv6Address? { nil }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import Logging
|
||||
|
||||
/// An object representing a Linux kernel used to boot a virtual machine.
|
||||
/// In addition to a path to the kernel itself, this type stores relevant
|
||||
/// data such as the commandline to pass to the kernel, and init arguments.
|
||||
public struct Kernel: Sendable, Codable {
|
||||
/// The command line arguments passed to the kernel on boot.
|
||||
public struct CommandLine: Sendable, Codable {
|
||||
public static let kernelDefaults = [
|
||||
"console=hvc0",
|
||||
"tsc=reliable",
|
||||
]
|
||||
|
||||
/// Adds the debug argument to the kernel commandline.
|
||||
mutating public func addDebug() {
|
||||
self.kernelArgs.append("debug")
|
||||
}
|
||||
|
||||
/// Adds a panic level to the kernel commandline.
|
||||
mutating public func addPanic(level: Int) {
|
||||
self.kernelArgs.append("panic=\(level)")
|
||||
}
|
||||
|
||||
// Sets the log level for the Agent
|
||||
mutating public func setAgentLogLevel(level: Logger.Level) {
|
||||
self.initArgs.append(contentsOf: ["--log-level", level.description])
|
||||
}
|
||||
|
||||
/// Additional kernel arguments.
|
||||
public var kernelArgs: [String]
|
||||
/// Additional arguments passed to the Initial Process / Agent.
|
||||
public var initArgs: [String]
|
||||
|
||||
/// Initializes the kernel commandline using the mix of kernel arguments
|
||||
/// and init arguments.
|
||||
public init(
|
||||
kernelArgs: [String] = kernelDefaults,
|
||||
initArgs: [String] = []
|
||||
) {
|
||||
self.kernelArgs = kernelArgs
|
||||
self.initArgs = initArgs
|
||||
}
|
||||
|
||||
/// Initializes the kernel commandline to the defaults of Self.kernelDefaults,
|
||||
/// adds a debug and panic flag as instructed, and optionally a set of init
|
||||
/// process flags to supply to vminitd.
|
||||
public init(debug: Bool, panic: Int, initArgs: [String] = []) {
|
||||
var args = Self.kernelDefaults
|
||||
if debug {
|
||||
args.append("debug")
|
||||
}
|
||||
args.append("panic=\(panic)")
|
||||
self.kernelArgs = args
|
||||
self.initArgs = initArgs
|
||||
}
|
||||
}
|
||||
|
||||
/// Path on disk to the kernel binary.
|
||||
public var path: URL
|
||||
/// Platform for the kernel.
|
||||
public var platform: SystemPlatform
|
||||
/// Kernel and init process command line.
|
||||
public var commandLine: Self.CommandLine
|
||||
|
||||
/// Kernel command line arguments.
|
||||
public var kernelArgs: [String] {
|
||||
self.commandLine.kernelArgs
|
||||
}
|
||||
|
||||
/// Init process arguments.
|
||||
public var initArgs: [String] {
|
||||
self.commandLine.initArgs
|
||||
}
|
||||
|
||||
public init(
|
||||
path: URL,
|
||||
platform: SystemPlatform,
|
||||
commandline: Self.CommandLine = CommandLine(debug: false, panic: 0)
|
||||
) {
|
||||
self.path = path
|
||||
self.platform = platform
|
||||
self.commandLine = commandline
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
/// `LinuxProcess` represents a Linux process and is used to
|
||||
/// setup and control the full lifecycle for the process.
|
||||
public final class LinuxProcess: Sendable {
|
||||
/// The ID of the process. This is purely metadata for the caller.
|
||||
public let id: String
|
||||
|
||||
/// What container owns this process (if any).
|
||||
public let owningContainer: String?
|
||||
|
||||
package struct StdioSetup: Sendable {
|
||||
let port: UInt32
|
||||
let writer: Writer
|
||||
}
|
||||
|
||||
package struct StdioReaderSetup {
|
||||
let port: UInt32
|
||||
let reader: ReaderStream
|
||||
}
|
||||
|
||||
package struct Stdio: Sendable {
|
||||
let stdin: StdioReaderSetup?
|
||||
let stdout: StdioSetup?
|
||||
let stderr: StdioSetup?
|
||||
}
|
||||
|
||||
private struct StdioHandles: Sendable {
|
||||
var stdin: FileHandle?
|
||||
var stdout: FileHandle?
|
||||
var stderr: FileHandle?
|
||||
|
||||
mutating func close() throws {
|
||||
if let stdin {
|
||||
try stdin.close()
|
||||
stdin.readabilityHandler = nil
|
||||
self.stdin = nil
|
||||
}
|
||||
if let stdout {
|
||||
try stdout.close()
|
||||
stdout.readabilityHandler = nil
|
||||
self.stdout = nil
|
||||
}
|
||||
if let stderr {
|
||||
try stderr.close()
|
||||
stderr.readabilityHandler = nil
|
||||
self.stderr = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var spec: ContainerizationOCI.Spec
|
||||
var pid: Int32
|
||||
var stdio: StdioHandles
|
||||
var stdinRelay: Task<(), Never>?
|
||||
var ioTracker: IoTracker?
|
||||
var deletionTask: Task<Void, Error>?
|
||||
|
||||
struct IoTracker {
|
||||
let stream: AsyncStream<Void>
|
||||
let cont: AsyncStream<Void>.Continuation
|
||||
let configuredStreams: Int
|
||||
}
|
||||
}
|
||||
|
||||
/// The process ID for the container process. This will be -1
|
||||
/// if the process has not been started.
|
||||
public var pid: Int32 {
|
||||
state.withLock { $0.pid }
|
||||
}
|
||||
|
||||
private let state: Mutex<State>
|
||||
private let ioSetup: Stdio
|
||||
private let agent: any VirtualMachineAgent
|
||||
private let vm: any VirtualMachineInstance
|
||||
private let ociRuntimePath: String?
|
||||
private let logger: Logger?
|
||||
private let onDelete: (@Sendable () async -> Void)?
|
||||
|
||||
init(
|
||||
_ id: String,
|
||||
containerID: String? = nil,
|
||||
spec: Spec,
|
||||
io: Stdio,
|
||||
ociRuntimePath: String?,
|
||||
agent: any VirtualMachineAgent,
|
||||
vm: any VirtualMachineInstance,
|
||||
logger: Logger?,
|
||||
onDelete: (@Sendable () async -> Void)? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.owningContainer = containerID
|
||||
self.state = Mutex<State>(.init(spec: spec, pid: -1, stdio: StdioHandles()))
|
||||
self.ioSetup = io
|
||||
self.agent = agent
|
||||
self.ociRuntimePath = ociRuntimePath
|
||||
self.vm = vm
|
||||
self.logger = logger
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
}
|
||||
|
||||
extension LinuxProcess {
|
||||
func setupIO(listeners: [VsockListener?]) async throws -> [FileHandle?] {
|
||||
let handles = try await Timeout.run(seconds: 3) {
|
||||
try await withThrowingTaskGroup(of: (Int, FileHandle?).self) { group in
|
||||
var results = [FileHandle?](repeating: nil, count: 3)
|
||||
|
||||
for (index, listener) in listeners.enumerated() {
|
||||
guard let listener else { continue }
|
||||
|
||||
group.addTask {
|
||||
let first = await listener.first(where: { _ in true })
|
||||
try listener.finish()
|
||||
return (index, first)
|
||||
}
|
||||
}
|
||||
|
||||
for try await (index, fileHandle) in group {
|
||||
results[index] = fileHandle
|
||||
}
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
// Note: stdin relay is started separately via startStdinRelay() after
|
||||
// the process has started, to avoid a deadlock where closeStdin is
|
||||
// called before the process is consuming from the pipe.
|
||||
|
||||
var configuredStreams = 0
|
||||
let (stream, cc) = AsyncStream<Void>.makeStream()
|
||||
if let stdout = self.ioSetup.stdout {
|
||||
configuredStreams += 1
|
||||
handles[1]?.readabilityHandler = { handle in
|
||||
do {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// This block is called when the producer (the guest) closes
|
||||
// the fd it is writing into.
|
||||
handles[1]?.readabilityHandler = nil
|
||||
cc.yield()
|
||||
return
|
||||
}
|
||||
try stdout.writer.write(data)
|
||||
} catch {
|
||||
self.logger?.error("failed to write to stdout: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stderr = self.ioSetup.stderr {
|
||||
configuredStreams += 1
|
||||
handles[2]?.readabilityHandler = { handle in
|
||||
do {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handles[2]?.readabilityHandler = nil
|
||||
cc.yield()
|
||||
return
|
||||
}
|
||||
try stderr.writer.write(data)
|
||||
} catch {
|
||||
self.logger?.error("failed to write to stderr: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if configuredStreams > 0 {
|
||||
self.state.withLock {
|
||||
$0.ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams)
|
||||
}
|
||||
}
|
||||
|
||||
return handles
|
||||
}
|
||||
|
||||
func startStdinRelay(handle: FileHandle) {
|
||||
guard let stdin = self.ioSetup.stdin else { return }
|
||||
|
||||
self.state.withLock {
|
||||
$0.stdinRelay = Task {
|
||||
for await data in stdin.reader.stream() {
|
||||
do {
|
||||
try handle.write(contentsOf: data)
|
||||
} catch {
|
||||
self.logger?.error("failed to write to stdin: \(error)")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
self.logger?.debug("stdin relay finished, closing")
|
||||
|
||||
// There's two ways we can wind up here:
|
||||
//
|
||||
// 1. The stream finished on its own (e.g. we wrote all the
|
||||
// data) and we will close the underlying stdin in the guest below.
|
||||
//
|
||||
// 2. The client explicitly called closeStdin() themselves
|
||||
// which will cancel this relay task AFTER actually closing
|
||||
// the fds. If the client did that, then this task will be
|
||||
// cancelled, and the fds are already gone so there's nothing
|
||||
// for us to do.
|
||||
if Task.isCancelled {
|
||||
return
|
||||
}
|
||||
|
||||
try await self._closeStdin()
|
||||
} catch {
|
||||
self.logger?.error("failed to close stdin: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the process.
|
||||
public func start() async throws {
|
||||
do {
|
||||
let spec = self.state.withLock { $0.spec }
|
||||
var listeners = [VsockListener?](repeating: nil, count: 3)
|
||||
if let stdin = self.ioSetup.stdin {
|
||||
listeners[0] = try self.vm.listen(stdin.port)
|
||||
}
|
||||
if let stdout = self.ioSetup.stdout {
|
||||
listeners[1] = try self.vm.listen(stdout.port)
|
||||
}
|
||||
if let stderr = self.ioSetup.stderr {
|
||||
if spec.process!.terminal {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "stderr should not be configured with terminal=true"
|
||||
)
|
||||
}
|
||||
listeners[2] = try self.vm.listen(stderr.port)
|
||||
}
|
||||
|
||||
let t = Task {
|
||||
try await self.setupIO(listeners: listeners)
|
||||
}
|
||||
|
||||
try await agent.createProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer,
|
||||
stdinPort: self.ioSetup.stdin?.port,
|
||||
stdoutPort: self.ioSetup.stdout?.port,
|
||||
stderrPort: self.ioSetup.stderr?.port,
|
||||
ociRuntimePath: self.ociRuntimePath,
|
||||
configuration: spec,
|
||||
options: nil
|
||||
)
|
||||
|
||||
let result = try await t.value
|
||||
let pid = try await self.agent.startProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer
|
||||
)
|
||||
|
||||
// Start stdin relay after process launch to avoid filling the pipe
|
||||
// buffer before the process is even running.
|
||||
if let stdinHandle = result[0] {
|
||||
self.startStdinRelay(handle: stdinHandle)
|
||||
}
|
||||
|
||||
self.state.withLock {
|
||||
$0.stdio = StdioHandles(
|
||||
stdin: result[0],
|
||||
stdout: result[1],
|
||||
stderr: result[2]
|
||||
)
|
||||
$0.pid = pid
|
||||
}
|
||||
} catch {
|
||||
if let err = error as? ContainerizationError {
|
||||
throw err
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to start process",
|
||||
cause: error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the process with the specified signal.
|
||||
public func kill(_ signal: Signal) async throws {
|
||||
do {
|
||||
try await agent.signalProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer,
|
||||
signal: signal.rawValue
|
||||
)
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to kill process",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the processes pty (if requested).
|
||||
public func resize(to: Terminal.Size) async throws {
|
||||
do {
|
||||
try await agent.resizeProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer,
|
||||
columns: UInt32(to.width),
|
||||
rows: UInt32(to.height)
|
||||
)
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to resize process",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func closeStdin() async throws {
|
||||
do {
|
||||
try await self._closeStdin()
|
||||
self.state.withLock {
|
||||
$0.stdinRelay?.cancel()
|
||||
}
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to close stdin",
|
||||
cause: error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func _closeStdin() async throws {
|
||||
try await self.agent.closeProcessStdin(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer
|
||||
)
|
||||
}
|
||||
|
||||
/// Wait on the process to exit with an optional timeout. Returns the exit code of the process.
|
||||
@discardableResult
|
||||
public func wait(timeoutInSeconds: Int64? = nil) async throws -> ExitStatus {
|
||||
do {
|
||||
let exitStatus = try await self.agent.waitProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer,
|
||||
timeoutInSeconds: timeoutInSeconds
|
||||
)
|
||||
await self.waitIoComplete()
|
||||
return exitStatus
|
||||
} catch {
|
||||
if error is ContainerizationError {
|
||||
throw error
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to wait on process",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait until the standard output and standard error streams for the process have concluded.
|
||||
private func waitIoComplete() async {
|
||||
let ioTracker = self.state.withLock { $0.ioTracker }
|
||||
guard let ioTracker else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await Timeout.run(seconds: 3) {
|
||||
var counter = ioTracker.configuredStreams
|
||||
for await _ in ioTracker.stream {
|
||||
counter -= 1
|
||||
if counter == 0 {
|
||||
ioTracker.cont.finish()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.logger?.error("timeout waiting for IO to complete for process \(id): \(error)")
|
||||
}
|
||||
self.state.withLock {
|
||||
$0.ioTracker = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up guest state and waits on and closes any host resources (stdio handles).
|
||||
public func delete() async throws {
|
||||
try await self._delete()
|
||||
await self.onDelete?()
|
||||
}
|
||||
|
||||
func _delete() async throws {
|
||||
let task = self.state.withLock { state in
|
||||
if let existingTask = state.deletionTask {
|
||||
// Deletion already in progress or finished.
|
||||
return existingTask
|
||||
}
|
||||
|
||||
let task = Task<Void, Error> {
|
||||
try await self.performDeletion()
|
||||
}
|
||||
state.deletionTask = task
|
||||
return task
|
||||
}
|
||||
|
||||
try await task.value
|
||||
}
|
||||
|
||||
private func performDeletion() async throws {
|
||||
do {
|
||||
try await self.agent.deleteProcess(
|
||||
id: self.id,
|
||||
containerID: self.owningContainer
|
||||
)
|
||||
} catch {
|
||||
self.state.withLock {
|
||||
$0.stdinRelay?.cancel()
|
||||
try? $0.stdio.close()
|
||||
}
|
||||
try? await self.agent.close()
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to delete process",
|
||||
cause: error,
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try self.state.withLock {
|
||||
$0.stdinRelay?.cancel()
|
||||
try $0.stdio.close()
|
||||
}
|
||||
} catch {
|
||||
try? await self.agent.close()
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to close stdio",
|
||||
cause: error,
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.agent.close()
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to close agent connection",
|
||||
cause: error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
|
||||
/// A resource limit (rlimit) configuration for a container process.
|
||||
public struct LinuxRLimit: Sendable, Hashable {
|
||||
/// The kind of resource limit.
|
||||
public var kind: Kind
|
||||
/// The hard limit value.
|
||||
public var hard: UInt64
|
||||
/// The soft limit value.
|
||||
public var soft: UInt64
|
||||
|
||||
/// Creates a new resource limit.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - kind: The kind of resource limit.
|
||||
/// - hard: The hard limit value.
|
||||
/// - soft: The soft limit value.
|
||||
public init(kind: Kind, hard: UInt64, soft: UInt64) {
|
||||
self.kind = kind
|
||||
self.hard = hard
|
||||
self.soft = soft
|
||||
}
|
||||
|
||||
/// Creates a new resource limit with the same value for both hard and soft limits.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - kind: The kind of resource limit.
|
||||
/// - limit: The limit value for both hard and soft limits.
|
||||
public init(kind: Kind, limit: UInt64) {
|
||||
self.kind = kind
|
||||
self.hard = limit
|
||||
self.soft = limit
|
||||
}
|
||||
|
||||
/// Convert to OCI POSIXRlimit format for transport.
|
||||
public func toOCI() -> POSIXRlimit {
|
||||
POSIXRlimit(type: self.kind.description, hard: self.hard, soft: self.soft)
|
||||
}
|
||||
}
|
||||
|
||||
extension LinuxRLimit {
|
||||
/// The kind of resource limit.
|
||||
public struct Kind: Sendable, Hashable {
|
||||
private enum Value: Hashable, Sendable, CaseIterable {
|
||||
case addressSpace
|
||||
case coreFileSize
|
||||
case cpuTime
|
||||
case dataSize
|
||||
case fileSize
|
||||
case locks
|
||||
case lockedMemory
|
||||
case messageQueue
|
||||
case nice
|
||||
case openFiles
|
||||
case numberOfProcesses
|
||||
case residentSetSize
|
||||
case realtimePriority
|
||||
case realtimeTimeout
|
||||
case signalsPending
|
||||
case stackSize
|
||||
}
|
||||
|
||||
private var value: Value
|
||||
private init(_ value: Value) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
/// Maximum size of the process's virtual memory (address space) in bytes.
|
||||
public static var addressSpace: Self {
|
||||
Self(.addressSpace)
|
||||
}
|
||||
|
||||
/// Maximum size of a core file in bytes.
|
||||
public static var coreFileSize: Self {
|
||||
Self(.coreFileSize)
|
||||
}
|
||||
|
||||
/// Maximum amount of CPU time the process can consume in seconds.
|
||||
public static var cpuTime: Self {
|
||||
Self(.cpuTime)
|
||||
}
|
||||
|
||||
/// Maximum size of the process's data segment in bytes.
|
||||
public static var dataSize: Self {
|
||||
Self(.dataSize)
|
||||
}
|
||||
|
||||
/// Maximum size of files the process may create in bytes.
|
||||
public static var fileSize: Self {
|
||||
Self(.fileSize)
|
||||
}
|
||||
|
||||
/// Maximum number of file locks.
|
||||
public static var locks: Self {
|
||||
Self(.locks)
|
||||
}
|
||||
|
||||
/// Maximum number of bytes of memory that may be locked into RAM.
|
||||
public static var lockedMemory: Self {
|
||||
Self(.lockedMemory)
|
||||
}
|
||||
|
||||
/// Maximum number of bytes that can be allocated for POSIX message queues.
|
||||
public static var messageQueue: Self {
|
||||
Self(.messageQueue)
|
||||
}
|
||||
|
||||
/// Maximum nice value that can be set.
|
||||
public static var nice: Self {
|
||||
Self(.nice)
|
||||
}
|
||||
|
||||
/// Maximum number of open file descriptors.
|
||||
public static var openFiles: Self {
|
||||
Self(.openFiles)
|
||||
}
|
||||
|
||||
/// Maximum number of processes that can be created by the user.
|
||||
public static var numberOfProcesses: Self {
|
||||
Self(.numberOfProcesses)
|
||||
}
|
||||
|
||||
/// Maximum size of the process's resident set (physical memory) in bytes.
|
||||
public static var residentSetSize: Self {
|
||||
Self(.residentSetSize)
|
||||
}
|
||||
|
||||
/// Maximum real-time scheduling priority.
|
||||
public static var realtimePriority: Self {
|
||||
Self(.realtimePriority)
|
||||
}
|
||||
|
||||
/// Maximum amount of CPU time for real-time scheduling in microseconds.
|
||||
public static var realtimeTimeout: Self {
|
||||
Self(.realtimeTimeout)
|
||||
}
|
||||
|
||||
/// Maximum number of signals that may be queued.
|
||||
public static var signalsPending: Self {
|
||||
Self(.signalsPending)
|
||||
}
|
||||
|
||||
/// Maximum size of the process stack in bytes.
|
||||
public static var stackSize: Self {
|
||||
Self(.stackSize)
|
||||
}
|
||||
|
||||
/// Creates a Kind from its OCI string representation.
|
||||
///
|
||||
/// - Parameter string: The OCI string representation (e.g., "RLIMIT_NOFILE").
|
||||
/// - Throws: `ContainerizationError` with code `.invalidArgument` if the string doesn't match a known rlimit kind.
|
||||
public init(_ string: String) throws {
|
||||
switch string {
|
||||
case "RLIMIT_AS":
|
||||
self = .addressSpace
|
||||
case "RLIMIT_CORE":
|
||||
self = .coreFileSize
|
||||
case "RLIMIT_CPU":
|
||||
self = .cpuTime
|
||||
case "RLIMIT_DATA":
|
||||
self = .dataSize
|
||||
case "RLIMIT_FSIZE":
|
||||
self = .fileSize
|
||||
case "RLIMIT_LOCKS":
|
||||
self = .locks
|
||||
case "RLIMIT_MEMLOCK":
|
||||
self = .lockedMemory
|
||||
case "RLIMIT_MSGQUEUE":
|
||||
self = .messageQueue
|
||||
case "RLIMIT_NICE":
|
||||
self = .nice
|
||||
case "RLIMIT_NOFILE":
|
||||
self = .openFiles
|
||||
case "RLIMIT_NPROC":
|
||||
self = .numberOfProcesses
|
||||
case "RLIMIT_RSS":
|
||||
self = .residentSetSize
|
||||
case "RLIMIT_RTPRIO":
|
||||
self = .realtimePriority
|
||||
case "RLIMIT_RTTIME":
|
||||
self = .realtimeTimeout
|
||||
case "RLIMIT_SIGPENDING":
|
||||
self = .signalsPending
|
||||
case "RLIMIT_STACK":
|
||||
self = .stackSize
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid rlimit kind: '\(string)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LinuxRLimit.Kind: CustomStringConvertible {
|
||||
/// The OCI string representation of the resource limit kind.
|
||||
public var description: String {
|
||||
switch self.value {
|
||||
case .addressSpace:
|
||||
"RLIMIT_AS"
|
||||
case .coreFileSize:
|
||||
"RLIMIT_CORE"
|
||||
case .cpuTime:
|
||||
"RLIMIT_CPU"
|
||||
case .dataSize:
|
||||
"RLIMIT_DATA"
|
||||
case .fileSize:
|
||||
"RLIMIT_FSIZE"
|
||||
case .locks:
|
||||
"RLIMIT_LOCKS"
|
||||
case .lockedMemory:
|
||||
"RLIMIT_MEMLOCK"
|
||||
case .messageQueue:
|
||||
"RLIMIT_MSGQUEUE"
|
||||
case .nice:
|
||||
"RLIMIT_NICE"
|
||||
case .openFiles:
|
||||
"RLIMIT_NOFILE"
|
||||
case .numberOfProcesses:
|
||||
"RLIMIT_NPROC"
|
||||
case .residentSetSize:
|
||||
"RLIMIT_RSS"
|
||||
case .realtimePriority:
|
||||
"RLIMIT_RTPRIO"
|
||||
case .realtimeTimeout:
|
||||
"RLIMIT_RTTIME"
|
||||
case .signalsPending:
|
||||
"RLIMIT_SIGPENDING"
|
||||
case .stackSize:
|
||||
"RLIMIT_STACK"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-friendly Linux capabilities configuration
|
||||
public struct LinuxCapabilities: Sendable {
|
||||
/// Capabilities that define the maximum set of capabilities a process can have
|
||||
public var bounding: [CapabilityName] = []
|
||||
/// Capabilities that are actually in effect for the current process
|
||||
public var effective: [CapabilityName] = []
|
||||
/// Capabilities that can be inherited by child processes
|
||||
public var inheritable: [CapabilityName] = []
|
||||
/// Capabilities that are currently permitted for the process
|
||||
public var permitted: [CapabilityName] = []
|
||||
/// Capabilities that are preserved across execve() calls
|
||||
public var ambient: [CapabilityName] = []
|
||||
|
||||
/// Grant all capabilities
|
||||
public static let allCapabilities = LinuxCapabilities(
|
||||
bounding: CapabilityName.allCases,
|
||||
effective: CapabilityName.allCases,
|
||||
inheritable: CapabilityName.allCases,
|
||||
permitted: CapabilityName.allCases,
|
||||
ambient: CapabilityName.allCases
|
||||
)
|
||||
|
||||
/// Default configuration
|
||||
public static let defaultOCICapabilities = LinuxCapabilities(
|
||||
bounding: [
|
||||
.chown,
|
||||
.dacOverride,
|
||||
.fsetid,
|
||||
.fowner,
|
||||
.mknod,
|
||||
.netRaw,
|
||||
.setgid,
|
||||
.setuid,
|
||||
.setfcap,
|
||||
.setpcap,
|
||||
.netBindService,
|
||||
.sysChroot,
|
||||
.kill,
|
||||
.auditWrite,
|
||||
],
|
||||
effective: [
|
||||
.chown,
|
||||
.dacOverride,
|
||||
.fsetid,
|
||||
.fowner,
|
||||
.mknod,
|
||||
.netRaw,
|
||||
.setgid,
|
||||
.setuid,
|
||||
.setfcap,
|
||||
.setpcap,
|
||||
.netBindService,
|
||||
.sysChroot,
|
||||
.kill,
|
||||
.auditWrite,
|
||||
],
|
||||
permitted: [
|
||||
.chown,
|
||||
.dacOverride,
|
||||
.fsetid,
|
||||
.fowner,
|
||||
.mknod,
|
||||
.netRaw,
|
||||
.setgid,
|
||||
.setuid,
|
||||
.setfcap,
|
||||
.setpcap,
|
||||
.netBindService,
|
||||
.sysChroot,
|
||||
.kill,
|
||||
.auditWrite,
|
||||
],
|
||||
)
|
||||
|
||||
public init(
|
||||
bounding: [CapabilityName] = [],
|
||||
effective: [CapabilityName] = [],
|
||||
inheritable: [CapabilityName] = [],
|
||||
permitted: [CapabilityName] = [],
|
||||
ambient: [CapabilityName] = []
|
||||
) {
|
||||
self.bounding = bounding
|
||||
self.effective = effective
|
||||
self.inheritable = inheritable
|
||||
self.permitted = permitted
|
||||
self.ambient = ambient
|
||||
}
|
||||
|
||||
/// Convenience initializer that sets the same capabilities to effective, permitted, and bounding sets
|
||||
/// This matches the typical pattern used by containerd/runc
|
||||
public init(capabilities: [CapabilityName]) {
|
||||
self.bounding = capabilities
|
||||
self.effective = capabilities
|
||||
self.inheritable = []
|
||||
self.permitted = capabilities
|
||||
self.ambient = []
|
||||
}
|
||||
|
||||
/// Convert to OCI format for transport
|
||||
public func toOCI() -> ContainerizationOCI.LinuxCapabilities {
|
||||
ContainerizationOCI.LinuxCapabilities(
|
||||
bounding: bounding.isEmpty ? nil : bounding.map { $0.description },
|
||||
effective: effective.isEmpty ? nil : effective.map { $0.description },
|
||||
inheritable: inheritable.isEmpty ? nil : inheritable.map { $0.description },
|
||||
permitted: permitted.isEmpty ? nil : permitted.map { $0.description },
|
||||
ambient: ambient.isEmpty ? nil : ambient.map { $0.description }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxProcessConfiguration: Sendable {
|
||||
/// The default PATH value for a process.
|
||||
public static let defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||
|
||||
/// The arguments for the container process.
|
||||
public var arguments: [String] = []
|
||||
/// The environment variables for the container process.
|
||||
public var environmentVariables: [String] = ["PATH=\(Self.defaultPath)"]
|
||||
/// The working directory for the container process.
|
||||
public var workingDirectory: String = "/"
|
||||
/// The user the container process will run as.
|
||||
public var user: ContainerizationOCI.User = .init()
|
||||
/// The rlimits for the container process.
|
||||
public var rlimits: [LinuxRLimit] = []
|
||||
/// Whether to set the no_new_privileges bit on the container process. When true, the
|
||||
/// process and its children cannot gain additional privileges via setuid/setgid binaries
|
||||
/// or file capabilities.
|
||||
public var noNewPrivileges: Bool = false
|
||||
/// The Linux capabilities for the container process.
|
||||
public var capabilities: LinuxCapabilities = .allCapabilities
|
||||
/// Whether to allocate a pseudo terminal for the process. If you'd like interactive
|
||||
/// behavior and are planning to use a terminal for stdin/out/err on the client side,
|
||||
/// this should likely be set to true.
|
||||
public var terminal: Bool = false
|
||||
/// The stdin for the process.
|
||||
public var stdin: ReaderStream?
|
||||
/// The stdout for the process.
|
||||
public var stdout: Writer?
|
||||
/// The stderr for the process.
|
||||
public var stderr: Writer?
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(
|
||||
arguments: [String],
|
||||
environmentVariables: [String] = ["PATH=\(Self.defaultPath)"],
|
||||
workingDirectory: String = "/",
|
||||
user: ContainerizationOCI.User = .init(),
|
||||
rlimits: [LinuxRLimit] = [],
|
||||
noNewPrivileges: Bool = false,
|
||||
capabilities: LinuxCapabilities = .allCapabilities,
|
||||
terminal: Bool = false,
|
||||
stdin: ReaderStream? = nil,
|
||||
stdout: Writer? = nil,
|
||||
stderr: Writer? = nil
|
||||
) {
|
||||
self.arguments = arguments
|
||||
self.environmentVariables = environmentVariables
|
||||
self.workingDirectory = workingDirectory
|
||||
self.user = user
|
||||
self.rlimits = rlimits
|
||||
self.noNewPrivileges = noNewPrivileges
|
||||
self.capabilities = capabilities
|
||||
self.terminal = terminal
|
||||
self.stdin = stdin
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
}
|
||||
|
||||
public init(from config: ImageConfig) {
|
||||
self.workingDirectory = config.workingDir ?? "/"
|
||||
self.environmentVariables = config.env ?? []
|
||||
self.arguments = (config.entrypoint ?? []) + (config.cmd ?? [])
|
||||
self.user = {
|
||||
if let rawString = config.user {
|
||||
return User(username: rawString)
|
||||
}
|
||||
return User()
|
||||
}()
|
||||
}
|
||||
|
||||
/// Sets up IO to be handled by the passed in Terminal, and edits the
|
||||
/// process configuration to set the necessary state for using a pty.
|
||||
mutating public func setTerminalIO(terminal: Terminal) {
|
||||
self.environmentVariables.append("TERM=xterm")
|
||||
self.terminal = true
|
||||
self.stdin = terminal
|
||||
self.stdout = terminal
|
||||
}
|
||||
|
||||
func toOCI() -> ContainerizationOCI.Process {
|
||||
ContainerizationOCI.Process(
|
||||
args: self.arguments,
|
||||
cwd: self.workingDirectory,
|
||||
env: self.environmentVariables,
|
||||
noNewPrivileges: self.noNewPrivileges,
|
||||
capabilities: self.capabilities.toOCI(),
|
||||
user: self.user,
|
||||
rlimits: self.rlimits.map { $0.toOCI() },
|
||||
terminal: self.terminal
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Virtualization
|
||||
#endif
|
||||
|
||||
/// A filesystem mount exposed to a container.
|
||||
public struct Mount: Sendable {
|
||||
/// The filesystem or mount type. This is the string
|
||||
/// that will be used for the mount syscall itself.
|
||||
public var type: String
|
||||
/// The source path of the mount.
|
||||
public var source: String
|
||||
/// The destination path of the mount.
|
||||
public var destination: String
|
||||
/// Filesystem or mount specific options.
|
||||
public var options: [String]
|
||||
/// Runtime specific options. This can be used
|
||||
/// as a way to discern what kind of device a vmm
|
||||
/// should create for this specific mount (virtioblock
|
||||
/// virtiofs etc.).
|
||||
public let runtimeOptions: RuntimeOptions
|
||||
|
||||
/// A type representing a "hint" of what type
|
||||
/// of mount this really is (block, directory, purely
|
||||
/// guest mount) and a set of type specific options, if any.
|
||||
public enum RuntimeOptions: Sendable {
|
||||
case virtioblk([String])
|
||||
case virtiofs([String])
|
||||
case shared
|
||||
case any([String])
|
||||
}
|
||||
|
||||
public init(
|
||||
type: String,
|
||||
source: String,
|
||||
destination: String,
|
||||
options: [String],
|
||||
runtimeOptions: RuntimeOptions
|
||||
) {
|
||||
self.type = type
|
||||
self.source = source
|
||||
self.destination = destination
|
||||
self.options = options
|
||||
self.runtimeOptions = runtimeOptions
|
||||
}
|
||||
|
||||
/// Mount representing a virtio block device.
|
||||
public static func block(
|
||||
format: String,
|
||||
source: String,
|
||||
destination: String,
|
||||
options: [String] = [],
|
||||
runtimeOptions: [String] = []
|
||||
) -> Self {
|
||||
.init(
|
||||
type: format,
|
||||
source: source,
|
||||
destination: destination,
|
||||
options: options,
|
||||
runtimeOptions: .virtioblk(runtimeOptions)
|
||||
)
|
||||
}
|
||||
|
||||
/// Mount representing a virtiofs share.
|
||||
public static func share(
|
||||
source: String,
|
||||
destination: String,
|
||||
options: [String] = [],
|
||||
runtimeOptions: [String] = []
|
||||
) -> Self {
|
||||
.init(
|
||||
type: "virtiofs",
|
||||
source: source,
|
||||
destination: destination,
|
||||
options: options,
|
||||
runtimeOptions: .virtiofs(runtimeOptions)
|
||||
)
|
||||
}
|
||||
|
||||
/// A generic mount.
|
||||
public static func any(
|
||||
type: String,
|
||||
source: String,
|
||||
destination: String,
|
||||
options: [String] = [],
|
||||
runtimeOptions: [String] = []
|
||||
) -> Self {
|
||||
.init(
|
||||
type: type,
|
||||
source: source,
|
||||
destination: destination,
|
||||
options: options,
|
||||
runtimeOptions: .any(runtimeOptions)
|
||||
)
|
||||
}
|
||||
|
||||
/// A mount referencing a shared pod volume by name.
|
||||
public static func sharedMount(
|
||||
name: String,
|
||||
destination: String,
|
||||
options: [String] = []
|
||||
) -> Self {
|
||||
.init(
|
||||
type: "none",
|
||||
source: name,
|
||||
destination: destination,
|
||||
options: options,
|
||||
runtimeOptions: .shared
|
||||
)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Clone the Mount to the provided path.
|
||||
///
|
||||
/// This uses `clonefile` to provide a copy-on-write copy of the Mount.
|
||||
public func clone(to: String) throws -> Self {
|
||||
let fm = FileManager.default
|
||||
let src = self.source
|
||||
try fm.copyItem(atPath: src, toPath: to)
|
||||
|
||||
return .init(
|
||||
type: self.type,
|
||||
source: to,
|
||||
destination: self.destination,
|
||||
options: self.options,
|
||||
runtimeOptions: self.runtimeOptions
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
extension Mount {
|
||||
private enum StorageAttachmentType {
|
||||
case diskImage
|
||||
case networkBlockDevice
|
||||
}
|
||||
|
||||
private var storageAttachmentType: StorageAttachmentType {
|
||||
let nbdSchemes = ["nbd://", "nbds://", "nbd+unix://", "nbds+unix://"]
|
||||
if nbdSchemes.contains(where: { self.source.hasPrefix($0) }) {
|
||||
return .networkBlockDevice
|
||||
}
|
||||
return .diskImage
|
||||
}
|
||||
|
||||
func configure(config: inout VZVirtualMachineConfiguration) throws {
|
||||
switch self.runtimeOptions {
|
||||
case .virtioblk(let options):
|
||||
let device: VZStorageDeviceAttachment
|
||||
switch self.storageAttachmentType {
|
||||
case .networkBlockDevice:
|
||||
device = try VZNetworkBlockDeviceStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
|
||||
case .diskImage:
|
||||
device = try VZDiskImageStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
|
||||
}
|
||||
let attachment = VZVirtioBlockDeviceConfiguration(attachment: device)
|
||||
config.storageDevices.append(attachment)
|
||||
case .virtiofs(_):
|
||||
// VirtioFS mounts are handled centrally via VZMultipleDirectoryShare in VZVirtualMachineInstance
|
||||
// No per-mount device configuration needed
|
||||
break
|
||||
case .shared, .any:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VZDiskImageStorageDeviceAttachment {
|
||||
static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZDiskImageStorageDeviceAttachment {
|
||||
var synchronizationMode: VZDiskImageSynchronizationMode = .fsync
|
||||
var cachingMode: VZDiskImageCachingMode = .cached
|
||||
|
||||
for option in options {
|
||||
let split = option.split(separator: "=")
|
||||
if split.count != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
let key = String(split[0])
|
||||
let value = String(split[1])
|
||||
|
||||
switch key {
|
||||
case "vzDiskImageCachingMode":
|
||||
switch value {
|
||||
case "automatic":
|
||||
cachingMode = .automatic
|
||||
case "cached":
|
||||
cachingMode = .cached
|
||||
case "uncached":
|
||||
cachingMode = .uncached
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown vzDiskImageCachingMode value for virtio block device: \(value)"
|
||||
)
|
||||
}
|
||||
case "vzDiskImageSynchronizationMode":
|
||||
switch value {
|
||||
case "full":
|
||||
synchronizationMode = .full
|
||||
case "fsync":
|
||||
synchronizationMode = .fsync
|
||||
case "none":
|
||||
synchronizationMode = .none
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown vzDiskImageSynchronizationMode value for virtio block device: \(value)"
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown vmm option encountered: \(key)"
|
||||
)
|
||||
}
|
||||
}
|
||||
return try VZDiskImageStorageDeviceAttachment(
|
||||
url: URL(filePath: mount.source),
|
||||
readOnly: mount.readonly,
|
||||
cachingMode: cachingMode,
|
||||
synchronizationMode: synchronizationMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension VZNetworkBlockDeviceStorageDeviceAttachment {
|
||||
static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZNetworkBlockDeviceStorageDeviceAttachment {
|
||||
guard let url = URL(string: mount.source) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid NBD URL: \(mount.source)"
|
||||
)
|
||||
}
|
||||
|
||||
var timeout: TimeInterval = 5
|
||||
var synchronizationMode: VZDiskSynchronizationMode = .full
|
||||
|
||||
for option in options {
|
||||
let split = option.split(separator: "=")
|
||||
if split.count != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
let key = String(split[0])
|
||||
let value = String(split[1])
|
||||
|
||||
switch key {
|
||||
case "vzTimeout":
|
||||
guard let t = TimeInterval(value) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid vzTimeout value for NBD device: \(value)"
|
||||
)
|
||||
}
|
||||
timeout = t
|
||||
case "vzSynchronizationMode":
|
||||
switch value {
|
||||
case "full":
|
||||
synchronizationMode = .full
|
||||
case "none":
|
||||
synchronizationMode = .none
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown vzSynchronizationMode value for NBD device: \(value)"
|
||||
)
|
||||
}
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown vmm option encountered: \(key)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return try VZNetworkBlockDeviceStorageDeviceAttachment(
|
||||
url: url,
|
||||
timeout: timeout,
|
||||
isForcedReadOnly: mount.readonly,
|
||||
synchronizationMode: synchronizationMode
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
extension Mount {
|
||||
fileprivate var readonly: Bool {
|
||||
self.options.contains("ro")
|
||||
}
|
||||
|
||||
/// Returns true if this mount is a virtio block device.
|
||||
public var isBlock: Bool {
|
||||
if case .virtioblk = self.runtimeOptions {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationExtras
|
||||
|
||||
public struct NATInterface: Interface {
|
||||
public var ipv4Address: CIDRv4
|
||||
public var ipv4Gateway: IPv4Address?
|
||||
public var ipv6Address: CIDRv6?
|
||||
public var ipv6Gateway: IPv6Address?
|
||||
public var macAddress: MACAddress?
|
||||
public var mtu: UInt32
|
||||
|
||||
public init(
|
||||
ipv4Address: CIDRv4,
|
||||
ipv4Gateway: IPv4Address?,
|
||||
ipv6Address: CIDRv6? = nil,
|
||||
ipv6Gateway: IPv6Address? = nil,
|
||||
macAddress: MACAddress? = nil,
|
||||
mtu: UInt32 = 1500
|
||||
) {
|
||||
self.ipv4Address = ipv4Address
|
||||
self.ipv4Gateway = ipv4Gateway
|
||||
self.ipv6Address = ipv6Address
|
||||
self.ipv6Gateway = ipv6Gateway
|
||||
self.macAddress = macAddress
|
||||
self.mtu = mtu
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
|
||||
import vmnet
|
||||
import Virtualization
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Synchronization
|
||||
|
||||
/// An interface that uses NAT to provide an IP address for a given
|
||||
/// container/virtual machine.
|
||||
@available(macOS 26, *)
|
||||
public final class NATNetworkInterface: Interface, Sendable {
|
||||
public let ipv4Address: CIDRv4
|
||||
public let ipv4Gateway: IPv4Address?
|
||||
public let macAddress: MACAddress?
|
||||
public let mtu: UInt32
|
||||
|
||||
@available(macOS 26, *)
|
||||
// `reference` isn't used concurrently.
|
||||
public nonisolated(unsafe) let reference: vmnet_network_ref!
|
||||
|
||||
@available(macOS 26, *)
|
||||
public init(
|
||||
ipv4Address: CIDRv4,
|
||||
ipv4Gateway: IPv4Address?,
|
||||
reference: sending vmnet_network_ref,
|
||||
macAddress: MACAddress? = nil,
|
||||
mtu: UInt32 = 1500
|
||||
) {
|
||||
self.ipv4Address = ipv4Address
|
||||
self.ipv4Gateway = ipv4Gateway
|
||||
self.macAddress = macAddress
|
||||
self.mtu = mtu
|
||||
self.reference = reference
|
||||
}
|
||||
|
||||
@available(macOS, obsoleted: 26, message: "Use init(ipv4Address:ipv4Gateway:reference:macAddress:) instead")
|
||||
public init(
|
||||
ipv4Address: CIDRv4,
|
||||
ipv4Gateway: IPv4Address?,
|
||||
macAddress: MACAddress? = nil,
|
||||
mtu: UInt32 = 1500
|
||||
) {
|
||||
self.ipv4Address = ipv4Address
|
||||
self.ipv4Gateway = ipv4Gateway
|
||||
self.macAddress = macAddress
|
||||
self.mtu = mtu
|
||||
self.reference = nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(macOS 26, *)
|
||||
extension NATNetworkInterface: VZInterface {
|
||||
public func device() throws -> VZVirtioNetworkDeviceConfiguration {
|
||||
let config = VZVirtioNetworkDeviceConfiguration()
|
||||
if let macAddress = self.macAddress {
|
||||
guard let mac = VZMACAddress(string: macAddress.description) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)")
|
||||
}
|
||||
config.macAddress = mac
|
||||
}
|
||||
|
||||
config.attachment = VZVmnetNetworkDeviceAttachment(network: self.reference)
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,21 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A network that can allocate and release interfaces for use with containers.
|
||||
public protocol Network: Sendable {
|
||||
mutating func createInterface(_ id: String) throws -> Interface?
|
||||
mutating func releaseInterface(_ id: String) throws
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,493 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package com.apple.containerization.sandbox.v3;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// Context for interacting with a container's runtime environment.
|
||||
service SandboxContext {
|
||||
// Mount a filesystem.
|
||||
rpc Mount(MountRequest) returns (MountResponse);
|
||||
// Unmount a filesystem.
|
||||
rpc Umount(UmountRequest) returns (UmountResponse);
|
||||
// Set an environment variable on the init process.
|
||||
rpc Setenv(SetenvRequest) returns (SetenvResponse);
|
||||
// Get an environment variable from the init process.
|
||||
rpc Getenv(GetenvRequest) returns (GetenvResponse);
|
||||
// Create a new directory inside the sandbox.
|
||||
rpc Mkdir(MkdirRequest) returns (MkdirResponse);
|
||||
// Set sysctls in the context of the sandbox.
|
||||
rpc Sysctl(SysctlRequest) returns (SysctlResponse);
|
||||
// Set time in the guest.
|
||||
rpc SetTime(SetTimeRequest) returns (SetTimeResponse);
|
||||
// Set up an emulator in the guest for a specific binary format.
|
||||
rpc SetupEmulator(SetupEmulatorRequest) returns (SetupEmulatorResponse);
|
||||
// Write data to an existing or new file.
|
||||
rpc WriteFile(WriteFileRequest) returns (WriteFileResponse);
|
||||
// Copy a file or directory between the host and guest.
|
||||
// Data transfer happens over a dedicated vsock connection;
|
||||
// the gRPC stream is used only for control/metadata.
|
||||
rpc Copy(CopyRequest) returns (stream CopyResponse);
|
||||
// Stat a path in the guest filesystem.
|
||||
rpc Stat(StatRequest) returns (StatResponse);
|
||||
// Perform a filesystem operation on a mounted filesystem.
|
||||
rpc FilesystemOperation(FilesystemOperationRequest) returns (FilesystemOperationResponse);
|
||||
|
||||
// Create a new process inside the container.
|
||||
rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse);
|
||||
// Delete an existing process inside the container.
|
||||
rpc DeleteProcess(DeleteProcessRequest) returns (DeleteProcessResponse);
|
||||
// Start the provided process.
|
||||
rpc StartProcess(StartProcessRequest) returns (StartProcessResponse);
|
||||
// Send a signal to the provided process.
|
||||
rpc KillProcess(KillProcessRequest) returns (KillProcessResponse);
|
||||
// Wait for a process to exit and return the exit code.
|
||||
rpc WaitProcess(WaitProcessRequest) returns (WaitProcessResponse);
|
||||
// Resize the tty of a given process. This will error if the process does
|
||||
// not have a pty allocated.
|
||||
rpc ResizeProcess(ResizeProcessRequest) returns (ResizeProcessResponse);
|
||||
// Close IO for a given process.
|
||||
rpc CloseProcessStdin(CloseProcessStdinRequest) returns (CloseProcessStdinResponse);
|
||||
|
||||
// Get statistics for containers.
|
||||
rpc ContainerStatistics(ContainerStatisticsRequest) returns (ContainerStatisticsResponse);
|
||||
|
||||
// Proxy a vsock port to a unix domain socket in the guest, or vice versa.
|
||||
rpc ProxyVsock(ProxyVsockRequest) returns (ProxyVsockResponse);
|
||||
// Stop a vsock proxy to a unix domain socket.
|
||||
rpc StopVsockProxy(StopVsockProxyRequest) returns (StopVsockProxyResponse);
|
||||
|
||||
// Set the link state of a network interface.
|
||||
rpc IpLinkSet(IpLinkSetRequest) returns (IpLinkSetResponse);
|
||||
// Add an IPv4 address to a network interface.
|
||||
rpc IpAddrAdd(IpAddrAddRequest) returns (IpAddrAddResponse);
|
||||
// Add an IP route for a network interface.
|
||||
rpc IpRouteAddLink(IpRouteAddLinkRequest) returns (IpRouteAddLinkResponse);
|
||||
// Add an IP route for a network interface.
|
||||
rpc IpRouteAddDefault(IpRouteAddDefaultRequest) returns (IpRouteAddDefaultResponse);
|
||||
// Configure DNS resolver.
|
||||
rpc ConfigureDns(ConfigureDnsRequest) returns (ConfigureDnsResponse);
|
||||
// Configure /etc/hosts.
|
||||
rpc ConfigureHosts(ConfigureHostsRequest) returns (ConfigureHostsResponse);
|
||||
|
||||
// Perform the sync syscall.
|
||||
rpc Sync(SyncRequest) returns (SyncResponse);
|
||||
// Send a signal to a process via the PID.
|
||||
rpc Kill(KillRequest) returns (KillResponse);
|
||||
}
|
||||
|
||||
message Stdio {
|
||||
optional int32 stdinPort = 1;
|
||||
optional int32 stdoutPort = 2;
|
||||
optional int32 stderrPort = 3;
|
||||
}
|
||||
|
||||
message SetupEmulatorRequest {
|
||||
string binary_path = 1;
|
||||
string name = 2;
|
||||
string type = 3;
|
||||
string offset = 4;
|
||||
string magic = 5;
|
||||
string mask = 6;
|
||||
string flags = 7;
|
||||
}
|
||||
|
||||
message SetupEmulatorResponse {}
|
||||
|
||||
message SetTimeRequest {
|
||||
int64 sec = 1;
|
||||
int32 usec = 2;
|
||||
}
|
||||
|
||||
message SetTimeResponse {}
|
||||
|
||||
message SysctlRequest { map<string, string> settings = 1; }
|
||||
|
||||
message SysctlResponse {}
|
||||
|
||||
message ProxyVsockRequest {
|
||||
enum Action {
|
||||
INTO = 0;
|
||||
OUT_OF = 1;
|
||||
}
|
||||
string id = 1;
|
||||
uint32 vsock_port = 2;
|
||||
string guestPath = 3;
|
||||
optional uint32 guestSocketPermissions = 4;
|
||||
Action action = 5;
|
||||
}
|
||||
|
||||
message ProxyVsockResponse {}
|
||||
|
||||
message StopVsockProxyRequest { string id = 1; }
|
||||
|
||||
message StopVsockProxyResponse {}
|
||||
|
||||
message MountRequest {
|
||||
string type = 1;
|
||||
string source = 2;
|
||||
string destination = 3;
|
||||
repeated string options = 4;
|
||||
}
|
||||
|
||||
message MountResponse {}
|
||||
|
||||
message UmountRequest {
|
||||
string path = 1;
|
||||
int32 flags = 2;
|
||||
}
|
||||
|
||||
message UmountResponse {}
|
||||
|
||||
message SetenvRequest {
|
||||
string key = 1;
|
||||
optional string value = 2;
|
||||
}
|
||||
|
||||
message SetenvResponse {}
|
||||
|
||||
message GetenvRequest { string key = 1; }
|
||||
|
||||
message GetenvResponse { optional string value = 1; }
|
||||
|
||||
message CreateProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
optional uint32 stdin = 3;
|
||||
optional uint32 stdout = 4;
|
||||
optional uint32 stderr = 5;
|
||||
optional string ociRuntimePath = 6;
|
||||
bytes configuration = 7;
|
||||
optional bytes options = 8;
|
||||
}
|
||||
|
||||
message CreateProcessResponse {}
|
||||
|
||||
message WaitProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
}
|
||||
|
||||
message WaitProcessResponse {
|
||||
int32 exitCode = 1;
|
||||
google.protobuf.Timestamp exited_at = 2;
|
||||
}
|
||||
|
||||
message ResizeProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
uint32 rows = 3;
|
||||
uint32 columns = 4;
|
||||
}
|
||||
|
||||
message ResizeProcessResponse {}
|
||||
|
||||
message DeleteProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
}
|
||||
|
||||
message DeleteProcessResponse {}
|
||||
|
||||
message StartProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
}
|
||||
|
||||
message StartProcessResponse { int32 pid = 1; }
|
||||
|
||||
message KillProcessRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
int32 signal = 3;
|
||||
}
|
||||
|
||||
message KillProcessResponse { int32 result = 1; }
|
||||
|
||||
message CloseProcessStdinRequest {
|
||||
string id = 1;
|
||||
optional string containerID = 2;
|
||||
}
|
||||
|
||||
message CloseProcessStdinResponse {}
|
||||
|
||||
message MkdirRequest {
|
||||
string path = 1;
|
||||
bool all = 2;
|
||||
uint32 perms = 3;
|
||||
}
|
||||
|
||||
message MkdirResponse {}
|
||||
|
||||
message WriteFileRequest {
|
||||
message WriteFileFlags {
|
||||
bool create_parent_dirs = 1;
|
||||
bool append = 2;
|
||||
bool create_if_missing = 3;
|
||||
}
|
||||
string path = 1;
|
||||
bytes data = 2;
|
||||
uint32 mode = 3;
|
||||
WriteFileFlags flags = 4;
|
||||
}
|
||||
|
||||
message WriteFileResponse {}
|
||||
|
||||
message CopyRequest {
|
||||
enum Direction {
|
||||
// Copy from host into guest.
|
||||
COPY_IN = 0;
|
||||
// Copy from guest to host.
|
||||
COPY_OUT = 1;
|
||||
}
|
||||
// Direction of the copy operation.
|
||||
Direction direction = 1;
|
||||
// Path in the guest (destination for COPY_IN, source for COPY_OUT).
|
||||
string path = 2;
|
||||
// File mode for single-file COPY_IN (defaults to 0644 if not set).
|
||||
uint32 mode = 3;
|
||||
// Create parent directories if they don't exist.
|
||||
bool create_parents = 4;
|
||||
// Vsock port the host is listening on for data transfer.
|
||||
uint32 vsock_port = 5;
|
||||
// For COPY_IN: indicates the data arriving on vsock is a tar+gzip archive.
|
||||
bool is_archive = 6;
|
||||
}
|
||||
|
||||
message CopyResponse {
|
||||
enum Status {
|
||||
// Transfer metadata (first message for COPY_OUT: is_archive, total_size).
|
||||
METADATA = 0;
|
||||
// Data transfer completed successfully.
|
||||
COMPLETE = 1;
|
||||
}
|
||||
// What this response represents.
|
||||
Status status = 1;
|
||||
// For COPY_OUT METADATA: indicates the data on vsock will be a tar+gzip archive.
|
||||
bool is_archive = 2;
|
||||
// For COPY_OUT METADATA: total size in bytes (0 if unknown, e.g. for archives).
|
||||
uint64 total_size = 3;
|
||||
// Non-empty if an error occurred.
|
||||
string error = 4;
|
||||
}
|
||||
|
||||
message StatRequest { string path = 1; }
|
||||
|
||||
message Stat {
|
||||
uint64 dev = 1; // st_dev: ID of device containing file
|
||||
uint64 ino = 2; // st_ino: inode number
|
||||
uint32 mode = 3; // st_mode: file type and mode (permissions)
|
||||
uint64 nlink = 4; // st_nlink: number of hard links
|
||||
uint32 uid = 5; // st_uid: user ID of owner
|
||||
uint32 gid = 6; // st_gid: group ID of owner
|
||||
uint64 rdev = 7; // st_rdev: device ID (if special file)
|
||||
int64 size = 8; // st_size: total size in bytes
|
||||
int64 blksize = 9; // st_blksize: preferred block size for filesystem I/O
|
||||
int64 blocks = 10; // st_blocks: number of 512-byte blocks allocated
|
||||
google.protobuf.Timestamp atime = 11; // st_atim: time of last access
|
||||
google.protobuf.Timestamp mtime = 12; // st_mtim: time of last modification
|
||||
google.protobuf.Timestamp ctime = 13; // st_ctim: time of last status change
|
||||
}
|
||||
|
||||
message StatResponse {
|
||||
Stat stat = 1;
|
||||
string error = 2; // Non-empty if stat failed.
|
||||
}
|
||||
|
||||
message FiTrimParams {
|
||||
oneof schedule {
|
||||
OneShot one_shot = 1;
|
||||
}
|
||||
|
||||
message OneShot {}
|
||||
}
|
||||
message FiFreezeParams {}
|
||||
message FiThawParams {}
|
||||
|
||||
message FiTrimResult {
|
||||
uint64 trimmed_bytes = 1;
|
||||
}
|
||||
|
||||
message FilesystemOperationRequest {
|
||||
string path = 1;
|
||||
oneof operation {
|
||||
FiTrimParams trim = 2;
|
||||
FiFreezeParams freeze = 3;
|
||||
FiThawParams thaw = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message FilesystemOperationResponse {
|
||||
oneof result {
|
||||
FiTrimResult trim = 1;
|
||||
}
|
||||
}
|
||||
|
||||
message IpLinkSetRequest {
|
||||
string interface = 1;
|
||||
bool up = 2;
|
||||
optional uint32 mtu = 3;
|
||||
}
|
||||
|
||||
message IpLinkSetResponse {}
|
||||
|
||||
message IpAddrAddRequest {
|
||||
string interface = 1;
|
||||
string ipv4Address = 2;
|
||||
optional string ipv6Address = 3;
|
||||
}
|
||||
|
||||
message IpAddrAddResponse {}
|
||||
|
||||
message IpRouteAddLinkRequest {
|
||||
string interface = 1;
|
||||
string dstIpv4Addr = 2;
|
||||
string srcIpv4Addr = 3;
|
||||
optional string dstIpv6Addr = 4;
|
||||
optional string srcIpv6Addr = 5;
|
||||
}
|
||||
|
||||
message IpRouteAddLinkResponse {}
|
||||
|
||||
message IpRouteAddDefaultRequest {
|
||||
string interface = 1;
|
||||
string ipv4Gateway = 2;
|
||||
optional string ipv6Gateway = 3;
|
||||
}
|
||||
|
||||
message IpRouteAddDefaultResponse {}
|
||||
|
||||
message ConfigureDnsRequest {
|
||||
string location = 1;
|
||||
repeated string nameservers = 2;
|
||||
optional string domain = 3;
|
||||
repeated string searchDomains = 4;
|
||||
repeated string options = 5;
|
||||
}
|
||||
|
||||
message ConfigureDnsResponse {}
|
||||
|
||||
message ConfigureHostsRequest {
|
||||
message HostsEntry {
|
||||
string ipAddress = 1;
|
||||
repeated string hostnames = 2;
|
||||
optional string comment = 3;
|
||||
}
|
||||
string location = 1;
|
||||
repeated HostsEntry entries = 2;
|
||||
optional string comment = 3;
|
||||
}
|
||||
|
||||
message ConfigureHostsResponse {}
|
||||
|
||||
message SyncRequest {}
|
||||
message SyncResponse {}
|
||||
|
||||
message KillRequest {
|
||||
int32 pid = 1;
|
||||
int32 signal = 3;
|
||||
}
|
||||
|
||||
message KillResponse { int32 result = 1; }
|
||||
|
||||
// Categories of statistics that can be requested.
|
||||
enum StatCategory {
|
||||
STAT_CATEGORY_UNSPECIFIED = 0;
|
||||
STAT_CATEGORY_PROCESS = 1;
|
||||
STAT_CATEGORY_MEMORY = 2;
|
||||
STAT_CATEGORY_CPU = 3;
|
||||
STAT_CATEGORY_BLOCK_IO = 4;
|
||||
STAT_CATEGORY_NETWORK = 5;
|
||||
STAT_CATEGORY_MEMORY_EVENTS = 6;
|
||||
}
|
||||
|
||||
message ContainerStatisticsRequest {
|
||||
repeated string container_ids = 1; // Empty = all containers
|
||||
repeated StatCategory categories = 2; // Empty = all categories
|
||||
}
|
||||
|
||||
message ContainerStatisticsResponse {
|
||||
repeated ContainerStats containers = 1;
|
||||
}
|
||||
|
||||
message ContainerStats {
|
||||
string container_id = 1;
|
||||
ProcessStats process = 2;
|
||||
MemoryStats memory = 3;
|
||||
CPUStats cpu = 4;
|
||||
BlockIOStats block_io = 5;
|
||||
repeated NetworkStats networks = 6;
|
||||
MemoryEventStats memory_events = 7;
|
||||
}
|
||||
|
||||
message ProcessStats {
|
||||
uint64 current = 1;
|
||||
uint64 limit = 2; // 0 or max value = unlimited
|
||||
}
|
||||
|
||||
message MemoryStats {
|
||||
uint64 usage_bytes = 1;
|
||||
uint64 limit_bytes = 2;
|
||||
uint64 swap_usage_bytes = 3;
|
||||
uint64 swap_limit_bytes = 4;
|
||||
uint64 cache_bytes = 5;
|
||||
uint64 kernel_stack_bytes = 6;
|
||||
uint64 slab_bytes = 7;
|
||||
uint64 page_faults = 8;
|
||||
uint64 major_page_faults = 9;
|
||||
uint64 inactive_file = 10;
|
||||
uint64 anon = 11;
|
||||
uint64 workingset_refault_anon = 12;
|
||||
uint64 workingset_refault_file = 13;
|
||||
uint64 pgsteal_kswapd = 14;
|
||||
uint64 pgsteal_direct = 15;
|
||||
uint64 pgsteal_khugepaged = 16;
|
||||
}
|
||||
|
||||
message CPUStats {
|
||||
uint64 usage_usec = 1;
|
||||
uint64 user_usec = 2;
|
||||
uint64 system_usec = 3;
|
||||
uint64 throttling_periods = 4;
|
||||
uint64 throttled_periods = 5;
|
||||
uint64 throttled_time_usec = 6;
|
||||
}
|
||||
|
||||
message BlockIOStats {
|
||||
repeated BlockIOEntry devices = 1;
|
||||
}
|
||||
|
||||
message BlockIOEntry {
|
||||
uint64 major = 1;
|
||||
uint64 minor = 2;
|
||||
uint64 read_bytes = 3;
|
||||
uint64 write_bytes = 4;
|
||||
uint64 read_operations = 5;
|
||||
uint64 write_operations = 6;
|
||||
}
|
||||
|
||||
message NetworkStats {
|
||||
string interface = 1;
|
||||
uint64 receivedPackets = 2;
|
||||
uint64 transmittedPackets = 3;
|
||||
uint64 receivedBytes = 4;
|
||||
uint64 transmittedBytes = 5;
|
||||
uint64 receivedErrors = 6;
|
||||
uint64 transmittedErrors = 7;
|
||||
}
|
||||
|
||||
// Memory event counters from cgroup2's memory.events file.
|
||||
message MemoryEventStats {
|
||||
// Number of times the cgroup was reclaimed due to low memory.
|
||||
uint64 low = 1;
|
||||
// Number of times the cgroup exceeded its high memory limit.
|
||||
uint64 high = 2;
|
||||
// Number of times the cgroup hit its max memory limit.
|
||||
uint64 max = 3;
|
||||
// Number of times the cgroup triggered OOM.
|
||||
uint64 oom = 4;
|
||||
// Number of processes killed by OOM killer.
|
||||
uint64 oom_kill = 5;
|
||||
// Number of times charge for memory failed because of limit.
|
||||
uint64 oom_group_kill = 6;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 canImport(Darwin)
|
||||
import Darwin
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Musl)
|
||||
import Musl
|
||||
#else
|
||||
#error("Signal not supported on this platform.")
|
||||
#endif
|
||||
|
||||
/// A unix signal.
|
||||
public struct Signal: RawRepresentable, Hashable, Sendable {
|
||||
public let rawValue: Int32
|
||||
|
||||
public init(rawValue: Int32) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
/// Parse a signal from a string representation (e.g. "SIGKILL", "KILL", "9").
|
||||
public init(_ name: String, from map: [String: Int32] = Signal.linux) throws {
|
||||
var signalUpper = name.uppercased()
|
||||
signalUpper.trimPrefix("SIG")
|
||||
if let sig = Int32(signalUpper) {
|
||||
if !map.values.contains(sig) {
|
||||
throw SignalError.invalidSignal(name)
|
||||
}
|
||||
self.rawValue = sig
|
||||
return
|
||||
}
|
||||
guard let sig = map[signalUpper] else {
|
||||
throw SignalError.invalidSignal(name)
|
||||
}
|
||||
self.rawValue = sig
|
||||
}
|
||||
|
||||
// Signals that are commonly sent to containers and share the same
|
||||
// number across macOS/Linux.
|
||||
public static let hup = Signal(rawValue: 1)
|
||||
public static let int = Signal(rawValue: 2)
|
||||
public static let quit = Signal(rawValue: 3)
|
||||
public static let kill = Signal(rawValue: 9)
|
||||
public static let term = Signal(rawValue: 15)
|
||||
public static let winch = Signal(rawValue: 28)
|
||||
|
||||
/// Linux signals.
|
||||
public enum Linux {
|
||||
public static let hup = Signal(rawValue: 1)
|
||||
public static let int = Signal(rawValue: 2)
|
||||
public static let quit = Signal(rawValue: 3)
|
||||
public static let ill = Signal(rawValue: 4)
|
||||
public static let trap = Signal(rawValue: 5)
|
||||
public static let abrt = Signal(rawValue: 6)
|
||||
public static let bus = Signal(rawValue: 7)
|
||||
public static let fpe = Signal(rawValue: 8)
|
||||
public static let kill = Signal(rawValue: 9)
|
||||
public static let usr1 = Signal(rawValue: 10)
|
||||
public static let segv = Signal(rawValue: 11)
|
||||
public static let usr2 = Signal(rawValue: 12)
|
||||
public static let pipe = Signal(rawValue: 13)
|
||||
public static let alrm = Signal(rawValue: 14)
|
||||
public static let term = Signal(rawValue: 15)
|
||||
public static let stkflt = Signal(rawValue: 16)
|
||||
public static let chld = Signal(rawValue: 17)
|
||||
public static let cont = Signal(rawValue: 18)
|
||||
public static let stop = Signal(rawValue: 19)
|
||||
public static let tstp = Signal(rawValue: 20)
|
||||
public static let ttin = Signal(rawValue: 21)
|
||||
public static let ttou = Signal(rawValue: 22)
|
||||
public static let urg = Signal(rawValue: 23)
|
||||
public static let xcpu = Signal(rawValue: 24)
|
||||
public static let xfsz = Signal(rawValue: 25)
|
||||
public static let vtalrm = Signal(rawValue: 26)
|
||||
public static let prof = Signal(rawValue: 27)
|
||||
public static let winch = Signal(rawValue: 28)
|
||||
public static let io = Signal(rawValue: 29)
|
||||
public static let poll = Signal(rawValue: 29)
|
||||
public static let pwr = Signal(rawValue: 30)
|
||||
public static let sys = Signal(rawValue: 31)
|
||||
|
||||
public static func rtmin(offset: Int32 = 0) -> Signal {
|
||||
Signal(rawValue: 34 + offset)
|
||||
}
|
||||
|
||||
public static let rtmax = Signal(rawValue: 64)
|
||||
}
|
||||
|
||||
/// Darwin signals.
|
||||
public enum Darwin {
|
||||
public static let hup = Signal(rawValue: 1)
|
||||
public static let int = Signal(rawValue: 2)
|
||||
public static let quit = Signal(rawValue: 3)
|
||||
public static let ill = Signal(rawValue: 4)
|
||||
public static let trap = Signal(rawValue: 5)
|
||||
public static let abrt = Signal(rawValue: 6)
|
||||
public static let emt = Signal(rawValue: 7)
|
||||
public static let fpe = Signal(rawValue: 8)
|
||||
public static let kill = Signal(rawValue: 9)
|
||||
public static let bus = Signal(rawValue: 10)
|
||||
public static let segv = Signal(rawValue: 11)
|
||||
public static let sys = Signal(rawValue: 12)
|
||||
public static let pipe = Signal(rawValue: 13)
|
||||
public static let alrm = Signal(rawValue: 14)
|
||||
public static let term = Signal(rawValue: 15)
|
||||
public static let urg = Signal(rawValue: 16)
|
||||
public static let stop = Signal(rawValue: 17)
|
||||
public static let tstp = Signal(rawValue: 18)
|
||||
public static let cont = Signal(rawValue: 19)
|
||||
public static let chld = Signal(rawValue: 20)
|
||||
public static let ttin = Signal(rawValue: 21)
|
||||
public static let ttou = Signal(rawValue: 22)
|
||||
public static let io = Signal(rawValue: 23)
|
||||
public static let xcpu = Signal(rawValue: 24)
|
||||
public static let xfsz = Signal(rawValue: 25)
|
||||
public static let vtalrm = Signal(rawValue: 26)
|
||||
public static let prof = Signal(rawValue: 27)
|
||||
public static let winch = Signal(rawValue: 28)
|
||||
public static let info = Signal(rawValue: 29)
|
||||
public static let usr1 = Signal(rawValue: 30)
|
||||
public static let usr2 = Signal(rawValue: 31)
|
||||
}
|
||||
|
||||
/// All Linux signals including real-time signals (RTMIN through RTMAX).
|
||||
public static let linux: [String: Int32] = [
|
||||
"ABRT": 6,
|
||||
"ALRM": 14,
|
||||
"BUS": 7,
|
||||
"CHLD": 17,
|
||||
"CLD": 17,
|
||||
"CONT": 18,
|
||||
"FPE": 8,
|
||||
"HUP": 1,
|
||||
"ILL": 4,
|
||||
"INT": 2,
|
||||
"IO": 29,
|
||||
"IOT": 6,
|
||||
"KILL": 9,
|
||||
"PIPE": 13,
|
||||
"POLL": 29,
|
||||
"PROF": 27,
|
||||
"PWR": 30,
|
||||
"QUIT": 3,
|
||||
"SEGV": 11,
|
||||
"STKFLT": 16,
|
||||
"STOP": 19,
|
||||
"SYS": 31,
|
||||
"TERM": 15,
|
||||
"TRAP": 5,
|
||||
"TSTP": 20,
|
||||
"TTIN": 21,
|
||||
"TTOU": 22,
|
||||
"URG": 23,
|
||||
"USR1": 10,
|
||||
"USR2": 12,
|
||||
"VTALRM": 26,
|
||||
"WINCH": 28,
|
||||
"XCPU": 24,
|
||||
"XFSZ": 25,
|
||||
"RTMIN": 34,
|
||||
"RTMIN+1": 35,
|
||||
"RTMIN+2": 36,
|
||||
"RTMIN+3": 37,
|
||||
"RTMIN+4": 38,
|
||||
"RTMIN+5": 39,
|
||||
"RTMIN+6": 40,
|
||||
"RTMIN+7": 41,
|
||||
"RTMIN+8": 42,
|
||||
"RTMIN+9": 43,
|
||||
"RTMIN+10": 44,
|
||||
"RTMIN+11": 45,
|
||||
"RTMIN+12": 46,
|
||||
"RTMIN+13": 47,
|
||||
"RTMIN+14": 48,
|
||||
"RTMIN+15": 49,
|
||||
"RTMIN+16": 50,
|
||||
"RTMIN+17": 51,
|
||||
"RTMIN+18": 52,
|
||||
"RTMIN+19": 53,
|
||||
"RTMIN+20": 54,
|
||||
"RTMIN+21": 55,
|
||||
"RTMIN+22": 56,
|
||||
"RTMIN+23": 57,
|
||||
"RTMIN+24": 58,
|
||||
"RTMIN+25": 59,
|
||||
"RTMIN+26": 60,
|
||||
"RTMIN+27": 61,
|
||||
"RTMIN+28": 62,
|
||||
"RTMIN+29": 63,
|
||||
"RTMAX": 64,
|
||||
]
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
extension Signal {
|
||||
/// All signals for the macOS host.
|
||||
public static let platform: [String: Int32] = [
|
||||
"ABRT": SIGABRT,
|
||||
"ALRM": SIGALRM,
|
||||
"BUS": SIGBUS,
|
||||
"CHLD": SIGCHLD,
|
||||
"CONT": SIGCONT,
|
||||
"EMT": SIGEMT,
|
||||
"FPE": SIGFPE,
|
||||
"HUP": SIGHUP,
|
||||
"ILL": SIGILL,
|
||||
"INFO": SIGINFO,
|
||||
"INT": SIGINT,
|
||||
"IO": SIGIO,
|
||||
"IOT": SIGIOT,
|
||||
"KILL": SIGKILL,
|
||||
"PIPE": SIGPIPE,
|
||||
"PROF": SIGPROF,
|
||||
"QUIT": SIGQUIT,
|
||||
"SEGV": SIGSEGV,
|
||||
"STOP": SIGSTOP,
|
||||
"SYS": SIGSYS,
|
||||
"TERM": SIGTERM,
|
||||
"TRAP": SIGTRAP,
|
||||
"TSTP": SIGTSTP,
|
||||
"TTIN": SIGTTIN,
|
||||
"TTOU": SIGTTOU,
|
||||
"URG": SIGURG,
|
||||
"USR1": SIGUSR1,
|
||||
"USR2": SIGUSR2,
|
||||
"VTALRM": SIGVTALRM,
|
||||
"WINCH": SIGWINCH,
|
||||
"XCPU": SIGXCPU,
|
||||
"XFSZ": SIGXFSZ,
|
||||
]
|
||||
}
|
||||
|
||||
#elseif os(Linux)
|
||||
|
||||
extension Signal {
|
||||
/// All signals for the Linux host.
|
||||
public static let platform = linux
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
extension Signal {
|
||||
private static let platformToName: [Int32: String] =
|
||||
Dictionary(Signal.platform.map { ($0.value, $0.key) }, uniquingKeysWith: { first, _ in first })
|
||||
|
||||
/// Returns the canonical name for this signal on the current platform.
|
||||
public func platformName() -> String? {
|
||||
Self.platformName(self.rawValue)
|
||||
}
|
||||
|
||||
/// Returns the canonical name for a signal number on the current platform.
|
||||
public static func platformName(_ signal: Int32) -> String? {
|
||||
platformToName[signal]
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
extension Signal {
|
||||
/// Converts a macOS signal to the equivalent Linux signal.
|
||||
public func linuxSignal() -> Signal? {
|
||||
guard let name = Self.platformToName[self.rawValue],
|
||||
let linuxNumber = Signal.linux[name]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Signal(rawValue: linuxNumber)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension Signal: ExpressibleByIntegerLiteral {
|
||||
public init(integerLiteral value: Int32) {
|
||||
self.rawValue = value
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can be encountered for converting signals.
|
||||
public enum SignalError: Error, CustomStringConvertible {
|
||||
case invalidSignal(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidSignal(let sig):
|
||||
return "invalid signal: \(sig)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
/// `SystemPlatform` describes an operating system and architecture pair.
|
||||
/// This is primarily used to choose what kind of OCI image to pull from a
|
||||
/// registry.
|
||||
public struct SystemPlatform: Sendable, Codable {
|
||||
public enum OS: String, CaseIterable, Sendable, Codable {
|
||||
case linux
|
||||
case darwin
|
||||
}
|
||||
public let os: OS
|
||||
|
||||
public enum Architecture: String, CaseIterable, Sendable, Codable {
|
||||
case arm64
|
||||
case amd64
|
||||
}
|
||||
public let architecture: Architecture
|
||||
|
||||
public func ociPlatform() -> ContainerizationOCI.Platform {
|
||||
ContainerizationOCI.Platform(arch: architecture.rawValue, os: os.rawValue)
|
||||
}
|
||||
|
||||
public static var linuxArm: SystemPlatform { .init(os: .linux, architecture: .arm64) }
|
||||
public static var linuxAmd: SystemPlatform { .init(os: .linux, architecture: .amd64) }
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import Logging
|
||||
|
||||
actor TimeSyncer {
|
||||
private var task: Task<Void, Never>?
|
||||
private var context: Vminitd?
|
||||
private var paused: Bool
|
||||
private let logger: Logger?
|
||||
|
||||
init(logger: Logger?) {
|
||||
self.paused = false
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
func start(context: Vminitd, interval: Duration = .seconds(30)) {
|
||||
guard self.task == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
self.context = context
|
||||
self.task = Task {
|
||||
while true {
|
||||
do {
|
||||
do {
|
||||
try await Task.sleep(for: interval)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
guard !paused else {
|
||||
continue
|
||||
}
|
||||
|
||||
var timeval = timeval()
|
||||
guard gettimeofday(&timeval, nil) == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
|
||||
try await context.setTime(
|
||||
sec: Int64(timeval.tv_sec),
|
||||
usec: Int32(timeval.tv_usec)
|
||||
)
|
||||
} catch {
|
||||
self.logger?.error("failed to sync time with guest agent: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause() async {
|
||||
self.paused = true
|
||||
}
|
||||
|
||||
func resume() async {
|
||||
self.paused = false
|
||||
}
|
||||
|
||||
func close() async throws {
|
||||
guard let task else {
|
||||
// Already closed, nop.
|
||||
return
|
||||
}
|
||||
|
||||
task.cancel()
|
||||
await task.value
|
||||
|
||||
try await self.context?.close()
|
||||
self.task = nil
|
||||
self.context = nil
|
||||
}
|
||||
}
|
||||
@@ -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 Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Represents a UnixSocket that can be shared into or out of a container/guest.
|
||||
public struct UnixSocketConfiguration: Sendable {
|
||||
// TODO: Realistically, we can just hash this struct and use it as the "id".
|
||||
/// The unique identifier for this socket configuration.
|
||||
public var id: String {
|
||||
_id
|
||||
}
|
||||
|
||||
private let _id = UUID().uuidString
|
||||
|
||||
/// The path to the socket you'd like relayed. For .into
|
||||
/// direction this should be the path on the host to a unix socket.
|
||||
/// For direction .outOf this should be the path in the container/guest
|
||||
/// to a unix socket.
|
||||
public var source: URL
|
||||
|
||||
/// The path you'd like the socket to be relayed to. For .into
|
||||
/// direction this should be the path in the container/guest. For
|
||||
/// direction .outOf this should be the path on your host.
|
||||
public var destination: URL
|
||||
|
||||
/// What to set the file permissions of the unix socket being created
|
||||
/// to. For .into direction this will be the socket in the guest. For
|
||||
/// .outOf direction this will be the socket on the host.
|
||||
public var permissions: FilePermissions?
|
||||
|
||||
/// The direction of the relay. `.into` for sharing a unix socket on your
|
||||
/// host into the container/guest. `outOf` shares a socket in the container/guest
|
||||
/// onto your host.
|
||||
public var direction: Direction
|
||||
|
||||
/// Type that denotes the direction of the unix socket relay.
|
||||
public enum Direction: Sendable {
|
||||
/// Share the socket into the container/guest.
|
||||
case into
|
||||
/// Share a socket in the container/guest onto the host.
|
||||
case outOf
|
||||
}
|
||||
|
||||
public init(
|
||||
source: URL,
|
||||
destination: URL,
|
||||
permissions: FilePermissions? = nil,
|
||||
direction: Direction = .into
|
||||
) {
|
||||
self.source = source
|
||||
self.destination = destination
|
||||
self.permissions = permissions
|
||||
self.direction = direction
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationIO
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import Synchronization
|
||||
|
||||
package final class UnixSocketRelay: Sendable {
|
||||
private let port: UInt32
|
||||
private let configuration: UnixSocketConfiguration
|
||||
private let vm: any VirtualMachineInstance
|
||||
private let log: Logger?
|
||||
private let state: Mutex<State>
|
||||
|
||||
private struct State {
|
||||
var activeRelays: [String: BidirectionalRelay] = [:]
|
||||
var t: Task<(), Never>? = nil
|
||||
var listener: VsockListener? = nil
|
||||
}
|
||||
|
||||
init(
|
||||
port: UInt32,
|
||||
socket: UnixSocketConfiguration,
|
||||
vm: any VirtualMachineInstance,
|
||||
log: Logger? = nil
|
||||
) throws {
|
||||
self.port = port
|
||||
self.configuration = socket
|
||||
self.vm = vm
|
||||
self.log = log
|
||||
self.state = Mutex<State>(.init())
|
||||
}
|
||||
|
||||
deinit {
|
||||
state.withLock { $0.t?.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
extension UnixSocketRelay {
|
||||
func start() async throws {
|
||||
switch configuration.direction {
|
||||
case .outOf:
|
||||
try await setupHostVsockDial()
|
||||
case .into:
|
||||
try setupHostVsockListener()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() throws {
|
||||
try state.withLock {
|
||||
guard let t = $0.t else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "failed to stop socket relay: relay has not been started"
|
||||
)
|
||||
}
|
||||
t.cancel()
|
||||
$0.t = nil
|
||||
for (_, relay) in $0.activeRelays {
|
||||
relay.stop()
|
||||
}
|
||||
$0.activeRelays.removeAll()
|
||||
|
||||
switch configuration.direction {
|
||||
case .outOf:
|
||||
// If we created the host conn, lets unlink it also. It's possible it was
|
||||
// already unlinked if the relay failed earlier.
|
||||
try? FileManager.default.removeItem(at: self.configuration.destination)
|
||||
case .into:
|
||||
try $0.listener?.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupHostVsockDial() async throws {
|
||||
let hostConn = configuration.destination
|
||||
|
||||
let socketType = try UnixType(
|
||||
path: hostConn.path,
|
||||
unlinkExisting: true
|
||||
)
|
||||
let hostSocket = try Socket(type: socketType)
|
||||
try hostSocket.listen()
|
||||
|
||||
log?.info(
|
||||
"listening on host UDS",
|
||||
metadata: [
|
||||
"path": "\(hostConn.path)",
|
||||
"vport": "\(port)",
|
||||
])
|
||||
let connectionStream = try hostSocket.acceptStream(closeOnDeinit: false)
|
||||
state.withLock {
|
||||
$0.t = Task {
|
||||
do {
|
||||
for try await connection in connectionStream {
|
||||
try await self.handleHostUnixConn(
|
||||
hostConn: connection,
|
||||
port: self.port,
|
||||
vm: self.vm,
|
||||
log: self.log
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
log?.error("failed in unix socket relay loop: \(error)")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: hostConn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupHostVsockListener() throws {
|
||||
let hostPath = configuration.source
|
||||
|
||||
let listener = try vm.listen(port)
|
||||
log?.info(
|
||||
"listening on guest vsock",
|
||||
metadata: [
|
||||
"path": "\(hostPath)",
|
||||
"vport": "\(port)",
|
||||
])
|
||||
|
||||
state.withLock {
|
||||
$0.listener = listener
|
||||
$0.t = Task {
|
||||
do {
|
||||
defer { try? listener.finish() }
|
||||
for await connection in listener {
|
||||
try await self.handleGuestVsockConn(
|
||||
vsockConn: connection,
|
||||
hostConnectionPath: hostPath,
|
||||
port: self.port,
|
||||
log: self.log
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
self.log?.error("failed to setup relay between vsock \(self.port) and \(hostPath.path): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHostUnixConn(
|
||||
hostConn: ContainerizationOS.Socket,
|
||||
port: UInt32,
|
||||
vm: any VirtualMachineInstance,
|
||||
log: Logger?
|
||||
) async throws {
|
||||
do {
|
||||
let guestConn = try await vm.dial(port)
|
||||
log?.debug(
|
||||
"initiating connection from host to guest",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"hostFd": "\(guestConn.fileDescriptor)",
|
||||
"guestFd": "\(hostConn.fileDescriptor)",
|
||||
])
|
||||
try await self.relay(
|
||||
hostConn: hostConn,
|
||||
guestFd: guestConn.fileDescriptor
|
||||
)
|
||||
} catch {
|
||||
log?.error("failed to relay between vsock \(port) and \(hostConn)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func handleGuestVsockConn(
|
||||
vsockConn: FileHandle,
|
||||
hostConnectionPath: URL,
|
||||
port: UInt32,
|
||||
log: Logger?
|
||||
) async throws {
|
||||
let hostPath = hostConnectionPath.path
|
||||
let socketType = try UnixType(path: hostPath)
|
||||
let hostSocket = try Socket(
|
||||
type: socketType,
|
||||
closeOnDeinit: false
|
||||
)
|
||||
log?.debug(
|
||||
"initiating connection from guest to host",
|
||||
metadata: [
|
||||
"vport": "\(port)",
|
||||
"hostFd": "\(hostSocket.fileDescriptor)",
|
||||
"guestFd": "\(vsockConn.fileDescriptor)",
|
||||
])
|
||||
try hostSocket.connect()
|
||||
|
||||
do {
|
||||
try await self.relay(
|
||||
hostConn: hostSocket,
|
||||
guestFd: vsockConn.fileDescriptor
|
||||
)
|
||||
} catch {
|
||||
log?.error("failed to relay between vsock \(port) and \(hostPath)")
|
||||
}
|
||||
}
|
||||
|
||||
private func relay(
|
||||
hostConn: Socket,
|
||||
guestFd: Int32
|
||||
) async throws {
|
||||
let hostFd = hostConn.fileDescriptor
|
||||
|
||||
let relayID = UUID().uuidString
|
||||
let relay = BidirectionalRelay(
|
||||
fd1: hostFd,
|
||||
fd2: guestFd,
|
||||
log: log
|
||||
)
|
||||
|
||||
state.withLock {
|
||||
$0.activeRelays[relayID] = relay
|
||||
}
|
||||
|
||||
do {
|
||||
try relay.start()
|
||||
} catch {
|
||||
state.withLock { $0.activeRelays[relayID] = nil }
|
||||
throw error
|
||||
}
|
||||
|
||||
Task {
|
||||
await relay.waitForCompletion()
|
||||
state.withLock { $0.activeRelays[relayID] = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
package actor UnixSocketRelayManager {
|
||||
private let vm: any VirtualMachineInstance
|
||||
private var relays: [String: UnixSocketRelay]
|
||||
private let log: Logger?
|
||||
|
||||
init(vm: any VirtualMachineInstance, log: Logger? = nil) {
|
||||
self.vm = vm
|
||||
self.relays = [:]
|
||||
self.log = log
|
||||
}
|
||||
}
|
||||
|
||||
extension UnixSocketRelayManager {
|
||||
func start(port: UInt32, socket: UnixSocketConfiguration) async throws {
|
||||
guard relays[socket.id] == nil else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "socket relay \(socket.id) already started"
|
||||
)
|
||||
}
|
||||
|
||||
let relay = try UnixSocketRelay(
|
||||
port: port,
|
||||
socket: socket,
|
||||
vm: vm,
|
||||
log: log
|
||||
)
|
||||
|
||||
do {
|
||||
relays[socket.id] = relay
|
||||
try await relay.start()
|
||||
} catch {
|
||||
relays.removeValue(forKey: socket.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func stop(socket: UnixSocketConfiguration) async throws {
|
||||
guard let storedRelay = relays.removeValue(forKey: socket.id) else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "failed to stop socket relay"
|
||||
)
|
||||
}
|
||||
try storedRelay.stop()
|
||||
}
|
||||
|
||||
func stopAll() async throws {
|
||||
for (_, relay) in relays {
|
||||
try relay.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
/// Destination for boot log (serial console) output.
|
||||
public struct BootLog: Sendable {
|
||||
/// The underlying representation of the boot log destination.
|
||||
internal enum Representation: Sendable {
|
||||
case file(path: URL, append: Bool)
|
||||
case fileHandle(FileHandle)
|
||||
}
|
||||
|
||||
internal var base: Representation
|
||||
|
||||
/// Write boot logs to a file at the specified path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: The URL of the file to write boot logs to.
|
||||
/// - append: Whether to append to an existing file or overwrite it. Defaults to true.
|
||||
///
|
||||
/// - Returns: A boot log destination that writes to a file.
|
||||
public static func file(path: URL, append: Bool = true) -> BootLog {
|
||||
self.init(base: .file(path: path, append: append))
|
||||
}
|
||||
|
||||
/// Write boot logs to a file handle.
|
||||
///
|
||||
/// - Parameter fileHandle: The file handle to write boot logs to.
|
||||
///
|
||||
/// - Returns: A boot log destination that writes to a file handle.
|
||||
public static func fileHandle(_ fileHandle: FileHandle) -> BootLog {
|
||||
self.init(base: .fileHandle(fileHandle))
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for VM creation configuration. Allows VMMs to extend with specific settings
|
||||
/// while maintaining a common core configuration.
|
||||
public protocol VMCreationConfig: Sendable {
|
||||
/// The common VM configuration that all VMMs must support.
|
||||
var configuration: VMConfiguration { get }
|
||||
}
|
||||
|
||||
/// Standard VM creation configuration with only common settings.
|
||||
public struct StandardVMConfig: VMCreationConfig {
|
||||
public var configuration: VMConfiguration
|
||||
|
||||
public init(configuration: VMConfiguration) {
|
||||
self.configuration = configuration
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for creating a virtual machine instance.
|
||||
public struct VMConfiguration: Sendable {
|
||||
/// The amount of CPUs to allocate.
|
||||
public var cpus: Int
|
||||
/// The memory in bytes to allocate.
|
||||
public var memoryInBytes: UInt64
|
||||
/// The network interfaces to attach.
|
||||
public var interfaces: [any Interface]
|
||||
/// Mounts organized by metadata ID (e.g. container ID).
|
||||
/// Each ID maps to an array of mounts for that workload.
|
||||
public var mountsByID: [String: [Mount]]
|
||||
/// Optional destination for serial boot logs.
|
||||
public var bootLog: BootLog?
|
||||
/// Enable nested virtualization support. If the VirtualMachineManager
|
||||
/// does not support this feature, it MUST return an .unsupported ContainerizationError.
|
||||
public var nestedVirtualization: Bool
|
||||
/// Extension objects that participate in the VM instance lifecycle.
|
||||
/// Extension packages append their types here; VZ-aware extensions
|
||||
/// should conform to ``VZInstanceExtension``.
|
||||
public var extensions: [any Sendable] = []
|
||||
|
||||
public init(
|
||||
cpus: Int = 4,
|
||||
memoryInBytes: UInt64 = 1024 * 1024 * 1024,
|
||||
interfaces: [any Interface] = [],
|
||||
mountsByID: [String: [Mount]] = [:],
|
||||
bootLog: BootLog? = nil,
|
||||
nestedVirtualization: Bool = false
|
||||
) {
|
||||
self.cpus = cpus
|
||||
self.memoryInBytes = memoryInBytes
|
||||
self.interfaces = interfaces
|
||||
self.mountsByID = mountsByID
|
||||
self.bootLog = bootLog
|
||||
self.nestedVirtualization = nestedVirtualization
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
import Foundation
|
||||
import Logging
|
||||
import Virtualization
|
||||
import ContainerizationError
|
||||
|
||||
extension VZVirtualMachine {
|
||||
nonisolated func connect(queue: DispatchQueue, port: UInt32) async throws -> VZVirtioSocketConnection {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
queue.sync {
|
||||
guard let vsock = self.socketDevices[0] as? VZVirtioSocketDevice else {
|
||||
let error = ContainerizationError(.invalidArgument, message: "no vsock device")
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
vsock.connect(toPort: port) { result in
|
||||
switch result {
|
||||
case .success(let conn):
|
||||
// `conn` isn't used concurrently.
|
||||
nonisolated(unsafe) let conn = conn
|
||||
cont.resume(returning: conn)
|
||||
case .failure(let error):
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listen(queue: DispatchQueue, port: UInt32, listener: VZVirtioSocketListener) throws {
|
||||
try queue.sync {
|
||||
guard let vsock = self.socketDevices[0] as? VZVirtioSocketDevice else {
|
||||
throw ContainerizationError(.invalidArgument, message: "no vsock device")
|
||||
}
|
||||
vsock.setSocketListener(listener, forPort: port)
|
||||
}
|
||||
}
|
||||
|
||||
func removeListener(queue: DispatchQueue, port: UInt32) throws {
|
||||
try queue.sync {
|
||||
guard let vsock = self.socketDevices[0] as? VZVirtioSocketDevice else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "no vsock device to remove"
|
||||
)
|
||||
}
|
||||
vsock.removeSocketListener(forPort: port)
|
||||
}
|
||||
}
|
||||
|
||||
func start(queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
queue.sync {
|
||||
self.start { result in
|
||||
if case .failure(let error) = result {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop(queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
queue.sync {
|
||||
self.stop { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pause(queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
queue.sync {
|
||||
self.pause { result in
|
||||
if case .failure(let error) = result {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resume(queue: DispatchQueue) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
queue.sync {
|
||||
self.resume { result in
|
||||
if case .failure(let error) = result {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
cont.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VZVirtualMachine {
|
||||
func waitForAgent(queue: DispatchQueue) async throws -> FileHandle {
|
||||
let agentConnectionRetryCount: Int = 200
|
||||
let agentConnectionSleepDuration: Duration = .milliseconds(20)
|
||||
|
||||
for _ in 0...agentConnectionRetryCount {
|
||||
do {
|
||||
return try await self.connect(queue: queue, port: Vminitd.port).dupHandle()
|
||||
} catch {
|
||||
try await Task.sleep(for: agentConnectionSleepDuration)
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw ContainerizationError(.timeout, message: "failed to get a connection to agent socket")
|
||||
}
|
||||
}
|
||||
|
||||
extension VZVirtioSocketConnection {
|
||||
func dupHandle() throws -> FileHandle {
|
||||
let fd = dup(self.fileDescriptor)
|
||||
if fd == -1 {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
self.close()
|
||||
return FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,619 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
import Foundation
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import Synchronization
|
||||
@preconcurrency import Virtualization
|
||||
|
||||
public final class VZVirtualMachineInstance: Sendable {
|
||||
public typealias Agent = Vminitd
|
||||
|
||||
/// Attached mounts on the virtual machine, organized by metadata ID.
|
||||
private let _mounts: Mutex<[String: [AttachedFilesystem]]>
|
||||
public var mounts: [String: [AttachedFilesystem]] {
|
||||
_mounts.withLock { $0 }
|
||||
}
|
||||
|
||||
/// The underlying Virtualization framework virtual machine.
|
||||
public var vzVirtualMachine: VZVirtualMachine { vm }
|
||||
|
||||
/// The dispatch queue used for VZ operations.
|
||||
public var vmQueue: DispatchQueue { queue }
|
||||
|
||||
/// Mutate the mount registry.
|
||||
public func withMountRegistry<T: Sendable>(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T {
|
||||
try _mounts.withLock(body)
|
||||
}
|
||||
|
||||
/// Serialize VM operations with the instance lock.
|
||||
public func withInstanceLock<T: Sendable>(_ body: @Sendable @escaping () async throws -> T) async throws -> T {
|
||||
try await lock.withLock { _ in try await body() }
|
||||
}
|
||||
|
||||
/// The hotplug provider, if hotplug is enabled for this instance.
|
||||
public var hotplugProvider: (any HotplugProvider)? {
|
||||
get { _hotplugProvider.withLock { $0 } }
|
||||
set { _hotplugProvider.withLock { $0 = newValue } }
|
||||
}
|
||||
private let _hotplugProvider = Mutex<(any HotplugProvider)?>(nil)
|
||||
|
||||
/// Returns the runtime state of the vm.
|
||||
public var state: VirtualMachineInstanceState {
|
||||
vzStateToInstanceState()
|
||||
}
|
||||
|
||||
/// The virtual machine instance configuration.
|
||||
private let config: Configuration
|
||||
public struct Configuration: Sendable {
|
||||
/// Amount of cpus to allocated.
|
||||
public var cpus: Int
|
||||
/// Amount of memory in bytes allocated.
|
||||
public var memoryInBytes: UInt64
|
||||
/// Toggle rosetta's x86_64 emulation support.
|
||||
public var rosetta: Bool
|
||||
/// Toggle nested virtualization support.
|
||||
public var nestedVirtualization: Bool
|
||||
/// Mount attachments organized by metadata ID.
|
||||
public var mountsByID: [String: [Mount]]
|
||||
/// Network interface attachments.
|
||||
public var interfaces: [any Interface]
|
||||
/// Kernel image.
|
||||
public var kernel: Kernel?
|
||||
/// The root filesystem.
|
||||
public var initialFilesystem: Mount?
|
||||
/// Destination for the virtual machine's boot logs.
|
||||
public var bootLog: BootLog?
|
||||
/// Extension objects that participate in the VM instance lifecycle.
|
||||
public var extensions: [any Sendable] = []
|
||||
|
||||
public init() {
|
||||
self.cpus = 4
|
||||
self.memoryInBytes = 1024.mib()
|
||||
self.rosetta = false
|
||||
self.nestedVirtualization = false
|
||||
self.mountsByID = [:]
|
||||
self.interfaces = []
|
||||
}
|
||||
}
|
||||
|
||||
// `vm` isn't used concurrently.
|
||||
private nonisolated(unsafe) let vm: VZVirtualMachine
|
||||
private let queue: DispatchQueue
|
||||
private let lock: AsyncLock
|
||||
private let group: EventLoopGroup
|
||||
private let ownsGroup: Bool
|
||||
private let timeSyncer: TimeSyncer
|
||||
private let logger: Logger?
|
||||
|
||||
public convenience init(
|
||||
group: EventLoopGroup? = nil,
|
||||
logger: Logger? = nil,
|
||||
with: (inout Configuration) throws -> Void
|
||||
) throws {
|
||||
var config = Configuration()
|
||||
try with(&config)
|
||||
try self.init(group: group, config: config, logger: logger)
|
||||
}
|
||||
|
||||
init(group: EventLoopGroup?, config: Configuration, logger: Logger?) throws {
|
||||
if let group {
|
||||
self.ownsGroup = false
|
||||
self.group = group
|
||||
} else {
|
||||
self.ownsGroup = true
|
||||
self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
}
|
||||
|
||||
self.config = config
|
||||
self.lock = .init()
|
||||
self.queue = DispatchQueue(label: "com.apple.containerization.vzvm.\(UUID().uuidString)")
|
||||
self.logger = logger
|
||||
self.timeSyncer = .init(logger: logger)
|
||||
|
||||
let allocator = Character.blockDeviceTagAllocator()
|
||||
let (mountAttachments, _) = try config.mountAttachments(allocator: allocator)
|
||||
self._mounts = Mutex(mountAttachments)
|
||||
|
||||
self.vm = VZVirtualMachine(
|
||||
configuration: try config.toVZ(allocator: allocator),
|
||||
queue: self.queue
|
||||
)
|
||||
|
||||
for ext in config.extensions.compactMap({ $0 as? any VZInstanceExtension }) {
|
||||
try ext.didCreate(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for extensions that participate in VZVirtualMachineInstance lifecycle.
|
||||
/// Append conforming types to `Configuration.extensions` to hook into VM setup and teardown.
|
||||
public protocol VZInstanceExtension: Sendable {
|
||||
/// Modify the VZ configuration before the VM is created.
|
||||
func configureVZ(
|
||||
_ config: inout VZVirtualMachineConfiguration,
|
||||
allocator: any AddressAllocator<Character>,
|
||||
storageDeviceCount: Int,
|
||||
mountsByID: [String: [Mount]]
|
||||
) throws
|
||||
|
||||
/// Called after the VZVirtualMachine is created but before start.
|
||||
func didCreate(_ instance: VZVirtualMachineInstance) throws
|
||||
|
||||
/// Called during stop before the VM is shut down.
|
||||
func willStop(_ instance: VZVirtualMachineInstance) async throws
|
||||
}
|
||||
|
||||
extension VZInstanceExtension {
|
||||
public func configureVZ(
|
||||
_ config: inout VZVirtualMachineConfiguration,
|
||||
allocator: any AddressAllocator<Character>,
|
||||
storageDeviceCount: Int,
|
||||
mountsByID: [String: [Mount]]
|
||||
) throws {}
|
||||
|
||||
public func didCreate(_ instance: VZVirtualMachineInstance) throws {}
|
||||
|
||||
public func willStop(_ instance: VZVirtualMachineInstance) async throws {}
|
||||
}
|
||||
|
||||
extension VZVirtualMachineInstance: VirtualMachineInstance {
|
||||
public func start() async throws {
|
||||
try await lock.withLock { _ in
|
||||
guard self.state == .stopped else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "virtual machine is not stopped \(self.state)"
|
||||
)
|
||||
}
|
||||
|
||||
// Do any necessary setup needed prior to starting the guest.
|
||||
try await self.prestart()
|
||||
|
||||
try await self.vm.start(queue: self.queue)
|
||||
|
||||
let agent = try Vminitd(
|
||||
connection: try await self.vm.waitForAgent(queue: self.queue),
|
||||
group: self.group
|
||||
)
|
||||
|
||||
do {
|
||||
if self.config.rosetta {
|
||||
try await agent.enableRosetta()
|
||||
}
|
||||
} catch {
|
||||
try await agent.close()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Don't close our remote context as we are providing
|
||||
// it to our time sync routine.
|
||||
await self.timeSyncer.start(context: agent)
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
try await lock.withLock { connections in
|
||||
// NOTE: We should record HOW the vm stopped eventually. If the vm exited
|
||||
// unexpectedly virtualization framework offers you a way to store
|
||||
// an error on how it exited. We should report that here instead of the
|
||||
// generic vm is not running.
|
||||
guard self.state == .running else {
|
||||
throw ContainerizationError(.invalidState, message: "vm is not running")
|
||||
}
|
||||
|
||||
try await self.timeSyncer.close()
|
||||
|
||||
if self.ownsGroup {
|
||||
try await self.group.shutdownGracefully()
|
||||
}
|
||||
|
||||
for ext in self.config.extensions.compactMap({ $0 as? any VZInstanceExtension }) {
|
||||
try? await ext.willStop(self)
|
||||
}
|
||||
|
||||
try await self.vm.stop(queue: self.queue)
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: Investigate what is the "right" way to handle already vended vsock
|
||||
// connections for pause and resume.
|
||||
|
||||
public func pause() async throws {
|
||||
try await lock.withLock { _ in
|
||||
await self.timeSyncer.pause()
|
||||
try await self.vm.pause(queue: self.queue)
|
||||
}
|
||||
}
|
||||
|
||||
public func resume() async throws {
|
||||
try await lock.withLock { _ in
|
||||
try await self.vm.resume(queue: self.queue)
|
||||
await self.timeSyncer.resume()
|
||||
}
|
||||
}
|
||||
|
||||
public func dialAgent() async throws -> Vminitd {
|
||||
try await lock.withLock { _ in
|
||||
do {
|
||||
let conn = try await self.vm.connect(
|
||||
queue: self.queue,
|
||||
port: Vminitd.port
|
||||
)
|
||||
let handle = try conn.dupHandle()
|
||||
return try Vminitd(connection: handle, group: self.group)
|
||||
} catch {
|
||||
if let err = error as? ContainerizationError {
|
||||
throw err
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to dial agent",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func dial(_ port: UInt32) async throws -> FileHandle {
|
||||
try await lock.withLock { _ in
|
||||
do {
|
||||
let conn = try await self.vm.connect(
|
||||
queue: self.queue,
|
||||
port: port
|
||||
)
|
||||
return try conn.dupHandle()
|
||||
} catch {
|
||||
if let err = error as? ContainerizationError {
|
||||
throw err
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to dial vsock port",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func listen(_ port: UInt32) throws -> VsockListener {
|
||||
let stream = VsockListener(port: port, stopListen: self.stopListen)
|
||||
let listener = VZVirtioSocketListener()
|
||||
listener.delegate = stream
|
||||
|
||||
try self.vm.listen(
|
||||
queue: queue,
|
||||
port: port,
|
||||
listener: listener
|
||||
)
|
||||
return stream
|
||||
}
|
||||
|
||||
private func stopListen(_ port: UInt32) throws {
|
||||
try self.vm.removeListener(
|
||||
queue: queue,
|
||||
port: port
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Hotplug
|
||||
|
||||
public func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem {
|
||||
guard let hotplugProvider else {
|
||||
throw ContainerizationError(.unsupported, message: "hotplug not supported")
|
||||
}
|
||||
return try await hotplugProvider.hotplug(block, id: id)
|
||||
}
|
||||
|
||||
public func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws {
|
||||
guard let hotplugProvider else { return }
|
||||
try hotplugProvider.registerMounts(id: id, rootfs: rootfs, additionalMounts: additionalMounts)
|
||||
}
|
||||
|
||||
public func releaseHotplug(id: String) async throws {
|
||||
guard let hotplugProvider else { return }
|
||||
try await hotplugProvider.releaseHotplug(id: id)
|
||||
}
|
||||
|
||||
public func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws {
|
||||
guard let hotplugProvider else { return }
|
||||
try await hotplugProvider.hotplugVirtioFS(mounts, id: id)
|
||||
}
|
||||
|
||||
public func releaseVirtioFS(id: String) async throws {
|
||||
guard let hotplugProvider else { return }
|
||||
try await hotplugProvider.releaseVirtioFS(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
extension VZVirtualMachineInstance {
|
||||
func vzStateToInstanceState() -> VirtualMachineInstanceState {
|
||||
self.queue.sync {
|
||||
let state: VirtualMachineInstanceState
|
||||
switch self.vm.state {
|
||||
case .starting:
|
||||
state = .starting
|
||||
case .running:
|
||||
state = .running
|
||||
case .stopping:
|
||||
state = .stopping
|
||||
case .stopped:
|
||||
state = .stopped
|
||||
default:
|
||||
state = .unknown
|
||||
}
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
func prestart() async throws {
|
||||
if self.config.rosetta {
|
||||
#if arch(arm64)
|
||||
if VZLinuxRosettaDirectoryShare.availability == .notInstalled {
|
||||
self.logger?.info("installing rosetta")
|
||||
try await VZVirtualMachineInstance.Configuration.installRosetta()
|
||||
}
|
||||
#else
|
||||
fatalError("rosetta is only supported on arm64")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VZVirtualMachineInstance.Configuration {
|
||||
public static func installRosetta() async throws {
|
||||
do {
|
||||
#if arch(arm64)
|
||||
try await VZLinuxRosettaDirectoryShare.installRosetta()
|
||||
#else
|
||||
fatalError("rosetta is only supported on arm64")
|
||||
#endif
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to install rosetta",
|
||||
cause: error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func serialPort(destination: BootLog) throws -> [VZVirtioConsoleDeviceSerialPortConfiguration] {
|
||||
let c = VZVirtioConsoleDeviceSerialPortConfiguration()
|
||||
switch destination.base {
|
||||
case .file(let path, let append):
|
||||
c.attachment = try VZFileSerialPortAttachment(url: path, append: append)
|
||||
case .fileHandle(let fileHandle):
|
||||
c.attachment = VZFileHandleSerialPortAttachment(
|
||||
fileHandleForReading: nil,
|
||||
fileHandleForWriting: fileHandle
|
||||
)
|
||||
}
|
||||
return [c]
|
||||
}
|
||||
|
||||
func toVZ(allocator: any AddressAllocator<Character>) throws -> VZVirtualMachineConfiguration {
|
||||
var config = VZVirtualMachineConfiguration()
|
||||
|
||||
config.cpuCount = self.cpus
|
||||
let mib: UInt64 = 1 << 20
|
||||
config.memorySize = (self.memoryInBytes + mib - 1) & ~(mib - 1)
|
||||
config.entropyDevices = [VZVirtioEntropyDeviceConfiguration()]
|
||||
config.socketDevices = [VZVirtioSocketDeviceConfiguration()]
|
||||
|
||||
if let bootLog = self.bootLog {
|
||||
config.serialPorts = try serialPort(destination: bootLog)
|
||||
} else {
|
||||
// We always supply a serial console. If no explicit path was provided just send em to the void.
|
||||
config.serialPorts = try serialPort(destination: .file(path: URL(filePath: "/dev/null")))
|
||||
}
|
||||
|
||||
config.networkDevices = try self.interfaces.map {
|
||||
guard let vzi = $0 as? VZInterface else {
|
||||
throw ContainerizationError(.invalidArgument, message: "interface type not supported by VZ")
|
||||
}
|
||||
return try vzi.device()
|
||||
}
|
||||
|
||||
if self.rosetta {
|
||||
#if arch(arm64)
|
||||
switch VZLinuxRosettaDirectoryShare.availability {
|
||||
case .notSupported:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "rosetta was requested but is not supported on this machine"
|
||||
)
|
||||
case .notInstalled:
|
||||
// NOTE: If rosetta isn't installed, we'll error with a nice error message
|
||||
// during .start() of the virtual machine instance.
|
||||
fallthrough
|
||||
case .installed:
|
||||
let share = try VZLinuxRosettaDirectoryShare()
|
||||
let device = VZVirtioFileSystemDeviceConfiguration(tag: "rosetta")
|
||||
device.share = share
|
||||
config.directorySharingDevices.append(device)
|
||||
@unknown default:
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown rosetta availability encountered: \(VZLinuxRosettaDirectoryShare.availability)"
|
||||
)
|
||||
}
|
||||
#else
|
||||
fatalError("rosetta is only supported on arm64")
|
||||
#endif
|
||||
}
|
||||
|
||||
guard let kernel = self.kernel else {
|
||||
throw ContainerizationError(.invalidArgument, message: "kernel cannot be nil")
|
||||
}
|
||||
|
||||
guard let initialFilesystem = self.initialFilesystem else {
|
||||
throw ContainerizationError(.invalidArgument, message: "rootfs cannot be nil")
|
||||
}
|
||||
|
||||
let loader = VZLinuxBootLoader(kernelURL: kernel.path)
|
||||
loader.commandLine = kernel.linuxCommandline(initialFilesystem: initialFilesystem)
|
||||
config.bootLoader = loader
|
||||
|
||||
try initialFilesystem.configure(config: &config)
|
||||
|
||||
// Track used virtiofs tags to avoid creating duplicate VZ devices.
|
||||
// The same source directory mounted to multiple destinations shares one device.
|
||||
var usedVirtioFSTags: Set<String> = []
|
||||
for (_, mounts) in self.mountsByID {
|
||||
for mount in mounts {
|
||||
if case .virtiofs = mount.runtimeOptions {
|
||||
let tag = try hashFilePath(path: mount.source)
|
||||
if usedVirtioFSTags.contains(tag) {
|
||||
continue
|
||||
}
|
||||
usedVirtioFSTags.insert(tag)
|
||||
}
|
||||
try mount.configure(config: &config)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the unified virtiofs device with VZMultipleDirectoryShare
|
||||
// This device hosts all virtiofs shares and supports runtime updates
|
||||
var directories: [String: VZSharedDirectory] = [:]
|
||||
for (_, mounts) in self.mountsByID {
|
||||
for mount in mounts {
|
||||
guard case .virtiofs(_) = mount.runtimeOptions else { continue }
|
||||
guard FileManager.default.fileExists(atPath: mount.source) else {
|
||||
throw ContainerizationError(.notFound, message: "directory \(mount.source) does not exist")
|
||||
}
|
||||
let name = try hashFilePath(path: mount.source)
|
||||
directories[name] = VZSharedDirectory(
|
||||
url: URL(fileURLWithPath: mount.source),
|
||||
readOnly: mount.options.contains("ro")
|
||||
)
|
||||
}
|
||||
}
|
||||
let multiShare = VZMultipleDirectoryShare(directories: directories)
|
||||
let virtiofsDevice = VZVirtioFileSystemDeviceConfiguration(tag: "virtiofs")
|
||||
virtiofsDevice.share = multiShare
|
||||
config.directorySharingDevices.append(virtiofsDevice)
|
||||
|
||||
let storageDeviceCount = config.storageDevices.count
|
||||
|
||||
let platform = VZGenericPlatformConfiguration()
|
||||
// We shouldn't silently succeed if the user asked for virt and their hardware does
|
||||
// not support it.
|
||||
if !VZGenericPlatformConfiguration.isNestedVirtualizationSupported && self.nestedVirtualization {
|
||||
throw ContainerizationError(
|
||||
.unsupported,
|
||||
message: "nested virtualization is not supported on the platform"
|
||||
)
|
||||
}
|
||||
platform.isNestedVirtualizationEnabled = self.nestedVirtualization
|
||||
config.platform = platform
|
||||
|
||||
for ext in self.extensions.compactMap({ $0 as? any VZInstanceExtension }) {
|
||||
try ext.configureVZ(&config, allocator: allocator, storageDeviceCount: storageDeviceCount, mountsByID: self.mountsByID)
|
||||
}
|
||||
|
||||
try config.validate()
|
||||
return config
|
||||
}
|
||||
|
||||
func mountAttachments(allocator: any AddressAllocator<Character>) throws -> (
|
||||
attachments: [String: [AttachedFilesystem]], storageDeviceCount: Int
|
||||
) {
|
||||
var storageDeviceCount = 0
|
||||
|
||||
if let initialFilesystem {
|
||||
// When the initial filesystem is a blk, allocate the first letter "vd(a)"
|
||||
// as that is what this blk will be attached under.
|
||||
if initialFilesystem.isBlock {
|
||||
_ = try allocator.allocate()
|
||||
storageDeviceCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
var attachmentsByID: [String: [AttachedFilesystem]] = [:]
|
||||
|
||||
for (id, mounts) in self.mountsByID {
|
||||
var attachments: [AttachedFilesystem] = []
|
||||
for mount in mounts {
|
||||
let attached = try AttachedFilesystem(mount: mount, allocator: allocator)
|
||||
attachments.append(attached)
|
||||
if mount.isBlock {
|
||||
storageDeviceCount += 1
|
||||
}
|
||||
}
|
||||
attachmentsByID[id] = attachments
|
||||
}
|
||||
|
||||
return (attachmentsByID, storageDeviceCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension Kernel {
|
||||
func linuxCommandline(initialFilesystem: Mount) -> String {
|
||||
var args = self.commandLine.kernelArgs
|
||||
|
||||
args.append("init=/sbin/vminitd")
|
||||
// rootfs is always set as ro.
|
||||
args.append("ro")
|
||||
|
||||
switch initialFilesystem.type {
|
||||
case "virtiofs":
|
||||
args.append(contentsOf: [
|
||||
"rootfstype=virtiofs",
|
||||
"root=rootfs",
|
||||
])
|
||||
case "ext4":
|
||||
args.append(contentsOf: [
|
||||
"rootfstype=ext4",
|
||||
"root=/dev/vda",
|
||||
])
|
||||
default:
|
||||
fatalError("unsupported initfs filesystem \(initialFilesystem.type)")
|
||||
}
|
||||
|
||||
if self.commandLine.initArgs.count > 0 {
|
||||
args.append("--")
|
||||
args.append(contentsOf: self.commandLine.initArgs)
|
||||
}
|
||||
|
||||
return args.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
public protocol VZInterface {
|
||||
func device() throws -> VZVirtioNetworkDeviceConfiguration
|
||||
}
|
||||
|
||||
extension NATInterface: VZInterface {
|
||||
public func device() throws -> VZVirtioNetworkDeviceConfiguration {
|
||||
let config = VZVirtioNetworkDeviceConfiguration()
|
||||
if let macAddress = self.macAddress {
|
||||
guard let mac = VZMACAddress(string: macAddress.description) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)")
|
||||
}
|
||||
config.macAddress = mac
|
||||
}
|
||||
config.attachment = VZNATNetworkDeviceAttachment()
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import Logging
|
||||
import NIOCore
|
||||
|
||||
/// A virtualization.framework backed `VirtualMachineManager` implementation.
|
||||
public struct VZVirtualMachineManager: VirtualMachineManager {
|
||||
private let kernel: Kernel
|
||||
private let initialFilesystem: Mount
|
||||
private let rosetta: Bool
|
||||
private let nestedVirtualization: Bool
|
||||
private let group: EventLoopGroup?
|
||||
private let logger: Logger?
|
||||
|
||||
public init(
|
||||
kernel: Kernel,
|
||||
initialFilesystem: Mount,
|
||||
rosetta: Bool = false,
|
||||
nestedVirtualization: Bool = false,
|
||||
group: EventLoopGroup? = nil,
|
||||
logger: Logger? = nil
|
||||
) {
|
||||
self.kernel = kernel
|
||||
self.initialFilesystem = initialFilesystem
|
||||
self.rosetta = rosetta
|
||||
self.nestedVirtualization = nestedVirtualization
|
||||
self.group = group
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
public func create(config: some VMCreationConfig) throws -> any VirtualMachineInstance {
|
||||
let vmConfig = config.configuration
|
||||
|
||||
// Use nested virtualization if requested in config or set as default in manager
|
||||
let useNestedVirtualization = vmConfig.nestedVirtualization || self.nestedVirtualization
|
||||
|
||||
// Clamp to system RAM as Virtualization.framework bounds us to this.
|
||||
let memoryInBytes = min(vmConfig.memoryInBytes, ProcessInfo.processInfo.physicalMemory)
|
||||
|
||||
// Clamp to system CPU count as Virtualization.framework bounds us to this.
|
||||
let cpus = min(vmConfig.cpus, ProcessInfo.processInfo.activeProcessorCount)
|
||||
|
||||
return try VZVirtualMachineInstance(
|
||||
group: self.group,
|
||||
logger: self.logger,
|
||||
with: { instanceConfig in
|
||||
instanceConfig.cpus = cpus
|
||||
instanceConfig.memoryInBytes = memoryInBytes
|
||||
|
||||
instanceConfig.kernel = self.kernel
|
||||
instanceConfig.initialFilesystem = self.initialFilesystem
|
||||
|
||||
if let bootLog = vmConfig.bootLog {
|
||||
instanceConfig.bootLog = bootLog
|
||||
}
|
||||
|
||||
instanceConfig.interfaces = vmConfig.interfaces
|
||||
instanceConfig.rosetta = self.rosetta
|
||||
instanceConfig.nestedVirtualization = useNestedVirtualization
|
||||
|
||||
instanceConfig.mountsByID = vmConfig.mountsByID
|
||||
instanceConfig.extensions = vmConfig.extensions
|
||||
})
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// Protocol to conform to if your agent is capable of relaying unix domain socket
|
||||
/// connections.
|
||||
public protocol SocketRelayAgent {
|
||||
func relaySocket(port: UInt32, configuration: UnixSocketConfiguration) async throws
|
||||
func stopSocketRelay(configuration: UnixSocketConfiguration) async throws
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationExtras
|
||||
import Logging
|
||||
|
||||
extension VirtualMachineAgent {
|
||||
/// Configure a single network interface inside the sandbox: assign addresses,
|
||||
/// bring the link up, and (when requested) install the link/default routes.
|
||||
func setupInterface(
|
||||
_ interface: any Interface,
|
||||
name: String,
|
||||
setDefaultRoute: Bool,
|
||||
logger: Logger?
|
||||
) async throws {
|
||||
logger?.debug("setting up interface \(name) with v4 \(interface.ipv4Address) v6 \(interface.ipv6Address?.description ?? "<none>")")
|
||||
try await addressAdd(
|
||||
name: name,
|
||||
address: .init(ipv4Address: interface.ipv4Address, ipv6Address: interface.ipv6Address)
|
||||
)
|
||||
try await up(name: name, mtu: interface.mtu)
|
||||
|
||||
guard setDefaultRoute else { return }
|
||||
|
||||
let ipv4Address = interface.ipv4Address
|
||||
let ipv4Gateway = interface.ipv4Gateway
|
||||
let ipv6Gateway = interface.ipv6Gateway
|
||||
let ipv6Address = interface.ipv6Address
|
||||
|
||||
let needsIPv4LinkRoute: Bool
|
||||
if let ipv4Gateway {
|
||||
needsIPv4LinkRoute = !ipv4Address.contains(ipv4Gateway)
|
||||
} else {
|
||||
needsIPv4LinkRoute = false
|
||||
}
|
||||
|
||||
let needsIPv6LinkRoute: Bool
|
||||
if let ipv6Gateway, let ipv6Address {
|
||||
needsIPv6LinkRoute = !ipv6Address.contains(ipv6Gateway)
|
||||
} else {
|
||||
needsIPv6LinkRoute = false
|
||||
}
|
||||
|
||||
if needsIPv4LinkRoute, let ipv4Gateway {
|
||||
logger?.debug("v4 gateway \(ipv4Gateway) is outside subnet \(ipv4Address), adding a route first")
|
||||
}
|
||||
if needsIPv6LinkRoute, let ipv6Gateway, let ipv6Address {
|
||||
logger?.debug("v6 gateway \(ipv6Gateway) is outside subnet \(ipv6Address), adding a route first")
|
||||
}
|
||||
|
||||
if needsIPv4LinkRoute || needsIPv6LinkRoute {
|
||||
try await routeAddLink(
|
||||
name: name,
|
||||
route: .init(
|
||||
ipv4Destination: needsIPv4LinkRoute ? ipv4Gateway : nil,
|
||||
ipv4Source: needsIPv4LinkRoute ? ipv4Address.address : nil,
|
||||
ipv6Destination: needsIPv6LinkRoute ? ipv6Gateway : nil,
|
||||
ipv6Source: needsIPv6LinkRoute ? ipv6Address?.address : nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if ipv4Gateway == nil && ipv6Gateway == nil {
|
||||
logger?.debug("no gateway for \(name)")
|
||||
}
|
||||
try await routeAddDefault(
|
||||
name: name,
|
||||
route: .init(ipv4Gateway: ipv4Gateway, ipv6Gateway: ipv6Gateway)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
public struct WriteFileFlags {
|
||||
public var createParentDirectories = false
|
||||
public var append = false
|
||||
public var create = false
|
||||
}
|
||||
|
||||
public enum FilesystemOperation: Sendable {
|
||||
case freeze
|
||||
case thaw
|
||||
case trim
|
||||
}
|
||||
|
||||
/// A protocol for the agent running inside a virtual machine. If an operation isn't
|
||||
/// supported the implementation MUST return a ContainerizationError with a code of
|
||||
/// `.unsupported`.
|
||||
public protocol VirtualMachineAgent: Sendable {
|
||||
/// Perform a platform specific standard setup
|
||||
/// of the runtime environment.
|
||||
func standardSetup() async throws
|
||||
/// Close any resources held by the agent.
|
||||
func close() async throws
|
||||
// Perform a filesystem operation on the given path.
|
||||
func filesystemOperation(operation: FilesystemOperation, path: String) async throws
|
||||
|
||||
// POSIX-y
|
||||
func getenv(key: String) async throws -> String
|
||||
func setenv(key: String, value: String) async throws
|
||||
func mount(_ mount: ContainerizationOCI.Mount) async throws
|
||||
func umount(path: String, flags: Int32) async throws
|
||||
func mkdir(path: String, all: Bool, perms: UInt32) async throws
|
||||
@discardableResult
|
||||
func kill(pid: Int32, signal: Int32) async throws -> Int32
|
||||
func sync() async throws
|
||||
func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws
|
||||
|
||||
// Process lifecycle
|
||||
func createProcess(
|
||||
id: String,
|
||||
containerID: String?,
|
||||
stdinPort: UInt32?,
|
||||
stdoutPort: UInt32?,
|
||||
stderrPort: UInt32?,
|
||||
ociRuntimePath: String?,
|
||||
configuration: ContainerizationOCI.Spec,
|
||||
options: Data?
|
||||
) async throws
|
||||
func startProcess(id: String, containerID: String?) async throws -> Int32
|
||||
func signalProcess(id: String, containerID: String?, signal: Int32) async throws
|
||||
func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws
|
||||
func waitProcess(id: String, containerID: String?, timeoutInSeconds: Int64?) async throws -> ExitStatus
|
||||
func deleteProcess(id: String, containerID: String?) async throws
|
||||
func closeProcessStdin(id: String, containerID: String?) async throws
|
||||
|
||||
// Networking
|
||||
func up(name: String, mtu: UInt32?) async throws
|
||||
func down(name: String) async throws
|
||||
func addressAdd(name: String, address: InterfaceAddress) async throws
|
||||
func routeAddLink(name: String, route: LinkRoute) async throws
|
||||
func routeAddDefault(name: String, route: DefaultRoute) async throws
|
||||
func configureDNS(config: DNS, location: String) async throws
|
||||
func configureHosts(config: Hosts, location: String) async throws
|
||||
|
||||
// Container statistics
|
||||
func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics]
|
||||
|
||||
}
|
||||
|
||||
extension VirtualMachineAgent {
|
||||
public func closeProcessStdin(id: String, containerID: String?) async throws {
|
||||
throw ContainerizationError(.unsupported, message: "closeProcessStdin")
|
||||
}
|
||||
|
||||
public func configureHosts(config: Hosts, location: String) async throws {
|
||||
throw ContainerizationError(.unsupported, message: "configureHosts")
|
||||
}
|
||||
|
||||
public func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws {
|
||||
throw ContainerizationError(.unsupported, message: "writeFile")
|
||||
}
|
||||
|
||||
public func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics] {
|
||||
throw ContainerizationError(.unsupported, message: "containerStatistics")
|
||||
}
|
||||
|
||||
public func sync() async throws {
|
||||
throw ContainerizationError(.unsupported, message: "sync")
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import Foundation
|
||||
|
||||
/// The runtime state of the virtual machine instance.
|
||||
public enum VirtualMachineInstanceState: Sendable {
|
||||
case starting
|
||||
case running
|
||||
case stopped
|
||||
case stopping
|
||||
case unknown
|
||||
}
|
||||
|
||||
/// A live instance of a virtual machine.
|
||||
public protocol VirtualMachineInstance: Sendable {
|
||||
associatedtype Agent: VirtualMachineAgent
|
||||
|
||||
// The state of the virtual machine.
|
||||
var state: VirtualMachineInstanceState { get }
|
||||
|
||||
var mounts: [String: [AttachedFilesystem]] { get }
|
||||
/// Dial the Agent. It's up the VirtualMachineInstance to determine
|
||||
/// what port the agent is listening on.
|
||||
func dialAgent() async throws -> Agent
|
||||
/// Dial a vsock port in the guest.
|
||||
func dial(_ port: UInt32) async throws -> FileHandle
|
||||
/// Listen on a host vsock port.
|
||||
func listen(_ port: UInt32) throws -> VsockListener
|
||||
/// Start the virtual machine.
|
||||
func start() async throws
|
||||
/// Stop the virtual machine.
|
||||
func stop() async throws
|
||||
/// Pause the virtual machine.
|
||||
func pause() async throws
|
||||
/// Resume the virtual machine.
|
||||
func resume() async throws
|
||||
|
||||
/// Hotplug a block device, returning the attached filesystem info.
|
||||
/// Throws if the VMM does not support hotplug or not available
|
||||
/// - Parameter block: The mount configuration for the block device to hotplug
|
||||
/// - Parameter id: The metadata ID to associate with this mount (e.g. container ID)
|
||||
/// - Returns: AttachedFilesystem with the device path in the guest
|
||||
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem
|
||||
|
||||
/// Register mounts for a container after hotplug.
|
||||
/// This is used to add the rootfs and additional mounts to the VM's mount registry
|
||||
/// so they can be found when building the container's OCI spec.
|
||||
/// - Parameter id: The container ID
|
||||
/// - Parameter rootfs: The rootfs attachment from hotplug
|
||||
/// - Parameter additionalMounts: Additional mounts (like /proc, /sys) to register
|
||||
func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws
|
||||
|
||||
/// Release a hotplug device.
|
||||
/// This should be called when a hotplugged container is stopped or fails to start.
|
||||
/// - Parameter id: The container ID whose hotplug should be released
|
||||
func releaseHotplug(id: String) async throws
|
||||
|
||||
/// Hotplug virtiofs directories into the running VM.
|
||||
/// - Parameter mounts: The virtiofs mounts to add
|
||||
/// - Parameter id: The container ID that owns these mounts
|
||||
func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws
|
||||
|
||||
/// Release virtiofs shares for a container.
|
||||
/// - Parameter id: The container ID whose virtiofs shares should be released
|
||||
func releaseVirtioFS(id: String) async throws
|
||||
}
|
||||
|
||||
extension VirtualMachineInstance {
|
||||
public func pause() async throws {
|
||||
throw ContainerizationError(.unsupported, message: "pause")
|
||||
}
|
||||
public func resume() async throws {
|
||||
throw ContainerizationError(.unsupported, message: "resume")
|
||||
}
|
||||
public func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem {
|
||||
throw ContainerizationError(.unsupported, message: "hotplug not supported")
|
||||
}
|
||||
public func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws {
|
||||
// no-op default
|
||||
}
|
||||
public func releaseHotplug(id: String) async throws {
|
||||
// no-op default
|
||||
}
|
||||
public func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws {
|
||||
// no-op default
|
||||
}
|
||||
public func releaseVirtioFS(id: String) async throws {
|
||||
// no-op default
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A protocol to implement for virtual machine isolated containers.
|
||||
public protocol VirtualMachineManager: Sendable {
|
||||
func create(config: some VMCreationConfig) async throws -> any VirtualMachineInstance
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationOS
|
||||
|
||||
extension Vminitd {
|
||||
/// Enable Rosetta's x86_64 emulation.
|
||||
public func enableRosetta() async throws {
|
||||
let path = "/run/rosetta"
|
||||
try await self.mount(
|
||||
.init(
|
||||
type: "virtiofs",
|
||||
source: "rosetta",
|
||||
destination: path
|
||||
)
|
||||
)
|
||||
try await self.setupEmulator(
|
||||
binaryPath: "\(path)/rosetta",
|
||||
configuration: Binfmt.Entry.amd64()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
extension Vminitd: SocketRelayAgent {
|
||||
/// Sets up a relay between a host socket to a newly created guest socket, or vice versa.
|
||||
public func relaySocket(port: UInt32, configuration: UnixSocketConfiguration) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest.with {
|
||||
$0.id = configuration.id
|
||||
$0.vsockPort = port
|
||||
|
||||
if let perms = configuration.permissions {
|
||||
$0.guestSocketPermissions = UInt32(perms.rawValue)
|
||||
}
|
||||
|
||||
switch configuration.direction {
|
||||
case .into:
|
||||
$0.guestPath = configuration.destination.path
|
||||
$0.action = .into
|
||||
case .outOf:
|
||||
$0.guestPath = configuration.source.path
|
||||
$0.action = .outOf
|
||||
}
|
||||
}
|
||||
_ = try await client.proxyVsock(request)
|
||||
}
|
||||
|
||||
/// Stops the specified socket relay.
|
||||
public func stopSocketRelay(configuration: UnixSocketConfiguration) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest.with {
|
||||
$0.id = configuration.id
|
||||
}
|
||||
_ = try await client.stopVsockProxy(request)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import GRPCCore
|
||||
import GRPCNIOTransportCore
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
/// A remote connection into the vminitd Linux guest agent via a port (vsock).
|
||||
/// Used to modify the runtime environment of the Linux sandbox.
|
||||
public struct Vminitd: Sendable {
|
||||
// Default vsock port that the agent and client use.
|
||||
public static let port: UInt32 = 1024
|
||||
|
||||
let client: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client<HTTP2ClientTransport.WrappedChannel>
|
||||
public let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
|
||||
private let connectionTask: Task<Void, Error>
|
||||
|
||||
public init(connection: FileHandle, group: any EventLoopGroup) throws {
|
||||
let channel = try ClientBootstrap(group: group)
|
||||
.channelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture(withResultOf: {
|
||||
try channel.pipeline.syncOperations.addHandler(HTTP2ConnectBufferingHandler())
|
||||
})
|
||||
}
|
||||
.withConnectedSocket(connection.fileDescriptor).wait()
|
||||
let transport = HTTP2ClientTransport.WrappedChannel.wrapping(
|
||||
channel: channel,
|
||||
config: .defaults { $0.connection.maxIdleTime = nil }
|
||||
)
|
||||
let grpcClient = GRPCClient(transport: transport)
|
||||
self.grpcClient = grpcClient
|
||||
self.client = Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client(wrapping: self.grpcClient)
|
||||
// Not very structured concurrency friendly, but we'd need to expose a way on the protocol to "run" the
|
||||
// agent otherwise, which some agents might not even need.
|
||||
self.connectionTask = Task {
|
||||
try await grpcClient.runConnections()
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the connection to the guest agent.
|
||||
public func close() async throws {
|
||||
self.grpcClient.beginGracefulShutdown()
|
||||
try await self.connectionTask.value
|
||||
}
|
||||
}
|
||||
|
||||
extension Vminitd: VirtualMachineAgent {
|
||||
/// Perform the standard guest setup necessary for vminitd to be able to
|
||||
/// run containers.
|
||||
public func standardSetup() async throws {
|
||||
try await up(name: "lo")
|
||||
|
||||
try await setenv(key: "PATH", value: LinuxProcessConfiguration.defaultPath)
|
||||
|
||||
// Vminitd mounts /proc, /sys, /sys/fs/cgroup and /run automatically.
|
||||
let mounts: [ContainerizationOCI.Mount] = [
|
||||
.init(type: "tmpfs", source: "tmpfs", destination: "/tmp"),
|
||||
.init(type: "devpts", source: "devpts", destination: "/dev/pts", options: ["gid=5", "mode=620", "ptmxmode=666"]),
|
||||
]
|
||||
for mount in mounts {
|
||||
try await self.mount(mount)
|
||||
}
|
||||
}
|
||||
|
||||
public func writeFile(path: String, data: Data, flags: WriteFileFlags, mode: UInt32) async throws {
|
||||
_ = try await client.writeFile(
|
||||
.with {
|
||||
$0.path = path
|
||||
$0.mode = mode
|
||||
$0.data = data
|
||||
$0.flags = .with {
|
||||
$0.append = flags.append
|
||||
$0.createIfMissing = flags.create
|
||||
$0.createParentDirs = flags.createParentDirectories
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Get statistics for containers. If `containerIDs` is empty returns stats for all containers
|
||||
/// in the guest. If `categories` is empty, all categories are returned.
|
||||
public func containerStatistics(containerIDs: [String], categories: StatCategory) async throws -> [ContainerStatistics] {
|
||||
let response = try await client.containerStatistics(
|
||||
.with {
|
||||
$0.containerIds = containerIDs
|
||||
$0.categories = categories.toProtoCategories()
|
||||
})
|
||||
|
||||
return response.containers.map { protoStats in
|
||||
ContainerStatistics(
|
||||
id: protoStats.containerID,
|
||||
process: categories.contains(.process) && protoStats.hasProcess
|
||||
? .init(
|
||||
current: protoStats.process.current,
|
||||
limit: protoStats.process.limit
|
||||
) : nil,
|
||||
memory: categories.contains(.memory) && protoStats.hasMemory
|
||||
? .init(
|
||||
usageBytes: protoStats.memory.usageBytes,
|
||||
limitBytes: protoStats.memory.limitBytes,
|
||||
swapUsageBytes: protoStats.memory.swapUsageBytes,
|
||||
swapLimitBytes: protoStats.memory.swapLimitBytes,
|
||||
cacheBytes: protoStats.memory.cacheBytes,
|
||||
kernelStackBytes: protoStats.memory.kernelStackBytes,
|
||||
slabBytes: protoStats.memory.slabBytes,
|
||||
pageFaults: protoStats.memory.pageFaults,
|
||||
majorPageFaults: protoStats.memory.majorPageFaults,
|
||||
inactiveFile: protoStats.memory.inactiveFile,
|
||||
anon: protoStats.memory.anon,
|
||||
workingsetRefaultAnon: protoStats.memory.workingsetRefaultAnon,
|
||||
workingsetRefaultFile: protoStats.memory.workingsetRefaultFile,
|
||||
pgstealKswapd: protoStats.memory.pgstealKswapd,
|
||||
pgstealDirect: protoStats.memory.pgstealDirect,
|
||||
pgstealKhugepaged: protoStats.memory.pgstealKhugepaged
|
||||
) : nil,
|
||||
cpu: categories.contains(.cpu) && protoStats.hasCpu
|
||||
? .init(
|
||||
usageUsec: protoStats.cpu.usageUsec,
|
||||
userUsec: protoStats.cpu.userUsec,
|
||||
systemUsec: protoStats.cpu.systemUsec,
|
||||
throttlingPeriods: protoStats.cpu.throttlingPeriods,
|
||||
throttledPeriods: protoStats.cpu.throttledPeriods,
|
||||
throttledTimeUsec: protoStats.cpu.throttledTimeUsec
|
||||
) : nil,
|
||||
blockIO: categories.contains(.blockIO) && protoStats.hasBlockIo
|
||||
? .init(
|
||||
devices: protoStats.blockIo.devices.map { device in
|
||||
.init(
|
||||
major: device.major,
|
||||
minor: device.minor,
|
||||
readBytes: device.readBytes,
|
||||
writeBytes: device.writeBytes,
|
||||
readOperations: device.readOperations,
|
||||
writeOperations: device.writeOperations
|
||||
)
|
||||
}
|
||||
) : nil,
|
||||
networks: categories.contains(.network)
|
||||
? protoStats.networks.map { network in
|
||||
ContainerStatistics.NetworkStatistics(
|
||||
interface: network.interface,
|
||||
receivedPackets: network.receivedPackets,
|
||||
transmittedPackets: network.transmittedPackets,
|
||||
receivedBytes: network.receivedBytes,
|
||||
transmittedBytes: network.transmittedBytes,
|
||||
receivedErrors: network.receivedErrors,
|
||||
transmittedErrors: network.transmittedErrors
|
||||
)
|
||||
} : nil,
|
||||
memoryEvents: categories.contains(.memoryEvents) && protoStats.hasMemoryEvents
|
||||
? .init(
|
||||
low: protoStats.memoryEvents.low,
|
||||
high: protoStats.memoryEvents.high,
|
||||
max: protoStats.memoryEvents.max,
|
||||
oom: protoStats.memoryEvents.oom,
|
||||
oomKill: protoStats.memoryEvents.oomKill
|
||||
) : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mount a filesystem in the sandbox's environment.
|
||||
public func mount(_ mount: ContainerizationOCI.Mount) async throws {
|
||||
_ = try await client.mount(
|
||||
.with {
|
||||
$0.type = mount.type
|
||||
$0.source = mount.source
|
||||
$0.destination = mount.destination
|
||||
$0.options = mount.options
|
||||
})
|
||||
}
|
||||
|
||||
/// Unmount a filesystem in the sandbox's environment.
|
||||
public func umount(path: String, flags: Int32) async throws {
|
||||
_ = try await client.umount(
|
||||
.with {
|
||||
$0.path = path
|
||||
$0.flags = flags
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a directory inside the sandbox's environment.
|
||||
public func mkdir(path: String, all: Bool, perms: UInt32) async throws {
|
||||
_ = try await client.mkdir(
|
||||
.with {
|
||||
$0.path = path
|
||||
$0.all = all
|
||||
$0.perms = perms
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform a filesystem operation on a path inside the sandbox's environment.
|
||||
public func filesystemOperation(operation: FilesystemOperation, path: String) async throws {
|
||||
_ = try await client.filesystemOperation(
|
||||
.with {
|
||||
$0.operation = operation.toProtoOperation()
|
||||
$0.path = path
|
||||
})
|
||||
}
|
||||
|
||||
public func createProcess(
|
||||
id: String,
|
||||
containerID: String?,
|
||||
stdinPort: UInt32?,
|
||||
stdoutPort: UInt32?,
|
||||
stderrPort: UInt32?,
|
||||
ociRuntimePath: String?,
|
||||
configuration: ContainerizationOCI.Spec,
|
||||
options: Data?
|
||||
) async throws {
|
||||
let enc = JSONEncoder()
|
||||
_ = try await client.createProcess(
|
||||
.with {
|
||||
$0.id = id
|
||||
if let stdinPort {
|
||||
$0.stdin = stdinPort
|
||||
}
|
||||
if let stdoutPort {
|
||||
$0.stdout = stdoutPort
|
||||
}
|
||||
if let stderrPort {
|
||||
$0.stderr = stderrPort
|
||||
}
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
if let ociRuntimePath {
|
||||
$0.ociRuntimePath = ociRuntimePath
|
||||
}
|
||||
$0.configuration = try enc.encode(configuration)
|
||||
})
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func startProcess(id: String, containerID: String?) async throws -> Int32 {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_StartProcessRequest.with {
|
||||
$0.id = id
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
}
|
||||
let resp = try await client.startProcess(request)
|
||||
return resp.pid
|
||||
}
|
||||
|
||||
public func signalProcess(id: String, containerID: String?, signal: Int32) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_KillProcessRequest.with {
|
||||
$0.id = id
|
||||
$0.signal = signal
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
}
|
||||
_ = try await client.killProcess(request)
|
||||
}
|
||||
|
||||
public func resizeProcess(id: String, containerID: String?, columns: UInt32, rows: UInt32) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest.with {
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
$0.id = id
|
||||
$0.columns = columns
|
||||
$0.rows = rows
|
||||
}
|
||||
_ = try await client.resizeProcess(request)
|
||||
}
|
||||
|
||||
public func waitProcess(
|
||||
id: String,
|
||||
containerID: String?,
|
||||
timeoutInSeconds: Int64? = nil
|
||||
) async throws -> ExitStatus {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest.with {
|
||||
$0.id = id
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
}
|
||||
|
||||
var callOpts = GRPCCore.CallOptions.defaults
|
||||
if let timeoutInSeconds {
|
||||
callOpts.timeout = .seconds(timeoutInSeconds)
|
||||
}
|
||||
|
||||
do {
|
||||
let resp = try await client.waitProcess(request, options: callOpts)
|
||||
return ExitStatus(exitCode: resp.exitCode, exitedAt: resp.exitedAt.date)
|
||||
} catch {
|
||||
if let err = error as? RPCError, err.code == .deadlineExceeded {
|
||||
throw ContainerizationError(
|
||||
.timeout,
|
||||
message: "failed to wait for process exit within timeout of \(timeoutInSeconds!) seconds",
|
||||
cause: err
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteProcess(id: String, containerID: String?) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest.with {
|
||||
$0.id = id
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
}
|
||||
_ = try await client.deleteProcess(request)
|
||||
}
|
||||
|
||||
public func closeProcessStdin(id: String, containerID: String?) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest.with {
|
||||
$0.id = id
|
||||
if let containerID {
|
||||
$0.containerID = containerID
|
||||
}
|
||||
}
|
||||
_ = try await client.closeProcessStdin(request)
|
||||
}
|
||||
|
||||
public func up(name: String, mtu: UInt32? = nil) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest.with {
|
||||
$0.interface = name
|
||||
$0.up = true
|
||||
if let mtu { $0.mtu = mtu }
|
||||
}
|
||||
_ = try await client.ipLinkSet(request)
|
||||
}
|
||||
|
||||
public func down(name: String) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest.with {
|
||||
$0.interface = name
|
||||
$0.up = false
|
||||
}
|
||||
_ = try await client.ipLinkSet(request)
|
||||
}
|
||||
|
||||
/// Get an environment variable from the sandbox's environment.
|
||||
public func getenv(key: String) async throws -> String {
|
||||
let response = try await client.getenv(
|
||||
.with {
|
||||
$0.key = key
|
||||
})
|
||||
return response.value
|
||||
}
|
||||
|
||||
/// Set an environment variable in the sandbox's environment.
|
||||
public func setenv(key: String, value: String) async throws {
|
||||
_ = try await client.setenv(
|
||||
.with {
|
||||
$0.key = key
|
||||
$0.value = value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Vminitd specific rpcs.
|
||||
extension Vminitd {
|
||||
/// Sets up an emulator in the guest.
|
||||
public func setupEmulator(binaryPath: String, configuration: Binfmt.Entry) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest.with {
|
||||
$0.binaryPath = binaryPath
|
||||
$0.name = configuration.name
|
||||
$0.type = configuration.type
|
||||
$0.offset = configuration.offset
|
||||
$0.magic = configuration.magic
|
||||
$0.mask = configuration.mask
|
||||
$0.flags = configuration.flags
|
||||
}
|
||||
_ = try await client.setupEmulator(request)
|
||||
}
|
||||
|
||||
/// Sets the guest time.
|
||||
public func setTime(sec: Int64, usec: Int32) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_SetTimeRequest.with {
|
||||
$0.sec = sec
|
||||
$0.usec = usec
|
||||
}
|
||||
_ = try await client.setTime(request)
|
||||
}
|
||||
|
||||
/// Set the provided sysctls inside the Sandbox's environment.
|
||||
public func sysctl(settings: [String: String]) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_SysctlRequest.with {
|
||||
$0.settings = settings
|
||||
}
|
||||
_ = try await client.sysctl(request)
|
||||
}
|
||||
|
||||
/// Add an IP address to the sandbox's network interfaces.
|
||||
public func addressAdd(name: String, address: InterfaceAddress) async throws {
|
||||
_ = try await client.ipAddrAdd(
|
||||
.with {
|
||||
$0.interface = name
|
||||
$0.ipv4Address = address.ipv4Address.description
|
||||
if let ipv6Address = address.ipv6Address {
|
||||
$0.ipv6Address = ipv6Address.description
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Add a link-scoped route in the sandbox's environment, used to install an
|
||||
/// on-link host route (a /32 for v4, /128 for v6) to a gateway that lives
|
||||
/// outside the interface's subnet so the kernel will accept the default route.
|
||||
/// `route.ipv4Destination`/`route.ipv6Destination` carry the
|
||||
/// gateway address; the wire format is a CIDR string with the per-family host prefix appended.
|
||||
public func routeAddLink(name: String, route: LinkRoute) async throws {
|
||||
_ = try await client.ipRouteAddLink(
|
||||
.with {
|
||||
$0.interface = name
|
||||
if let ipv4Destination = route.ipv4Destination {
|
||||
$0.dstIpv4Addr = "\(ipv4Destination.description)/32"
|
||||
}
|
||||
if let ipv4Source = route.ipv4Source {
|
||||
$0.srcIpv4Addr = ipv4Source.description
|
||||
}
|
||||
if let ipv6Destination = route.ipv6Destination {
|
||||
$0.dstIpv6Addr = "\(ipv6Destination.description)/128"
|
||||
}
|
||||
if let ipv6Source = route.ipv6Source {
|
||||
$0.srcIpv6Addr = ipv6Source.description
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Set the default route in the sandbox's environment.
|
||||
public func routeAddDefault(name: String, route: DefaultRoute) async throws {
|
||||
_ = try await client.ipRouteAddDefault(
|
||||
.with {
|
||||
$0.interface = name
|
||||
$0.ipv4Gateway = route.ipv4Gateway?.description ?? ""
|
||||
if let ipv6Gateway = route.ipv6Gateway {
|
||||
$0.ipv6Gateway = ipv6Gateway.description
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure DNS within the sandbox's environment.
|
||||
public func configureDNS(config: DNS, location: String) async throws {
|
||||
try config.validate()
|
||||
_ = try await client.configureDns(
|
||||
.with {
|
||||
$0.location = location
|
||||
$0.nameservers = config.nameservers
|
||||
if let domain = config.domain {
|
||||
$0.domain = domain
|
||||
}
|
||||
$0.searchDomains = config.searchDomains
|
||||
$0.options = config.options
|
||||
})
|
||||
}
|
||||
|
||||
/// Configure /etc/hosts within the sandbox's environment.
|
||||
public func configureHosts(config: Hosts, location: String) async throws {
|
||||
_ = try await client.configureHosts(config.toAgentHostsRequest(location: location))
|
||||
}
|
||||
|
||||
/// Perform a sync call.
|
||||
public func sync() async throws {
|
||||
_ = try await client.sync(.init())
|
||||
}
|
||||
|
||||
public func kill(pid: Int32, signal: Int32) async throws -> Int32 {
|
||||
let response = try await client.kill(
|
||||
.with {
|
||||
$0.pid = pid
|
||||
$0.signal = signal
|
||||
})
|
||||
return response.result
|
||||
}
|
||||
|
||||
/// Metadata received from the guest during a copy operation.
|
||||
public struct CopyMetadata: Sendable {
|
||||
/// Whether the data on the vsock channel is a tar+gzip archive.
|
||||
public let isArchive: Bool
|
||||
/// Total size in bytes (0 if unknown, e.g. for archives).
|
||||
public let totalSize: UInt64
|
||||
}
|
||||
|
||||
/// Stat a path in the guest filesystem and return its metadata.
|
||||
public func stat(
|
||||
path: URL
|
||||
) async throws -> ContainerizationOS.Stat {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_StatRequest.with {
|
||||
$0.path = path.path
|
||||
}
|
||||
|
||||
let response: Com_Apple_Containerization_Sandbox_V3_StatResponse
|
||||
do {
|
||||
response = try await client.stat(request)
|
||||
} catch let error as RPCError where error.code == .notFound {
|
||||
throw ContainerizationError(.notFound, message: "stat: path not found '\(path.path)'", cause: error)
|
||||
}
|
||||
guard response.error.isEmpty else {
|
||||
throw ContainerizationError(.internalError, message: "stat: \(response.error)")
|
||||
}
|
||||
|
||||
let s = response.stat
|
||||
return ContainerizationOS.Stat(
|
||||
dev: s.dev,
|
||||
ino: s.ino,
|
||||
mode: s.mode,
|
||||
nlink: s.nlink,
|
||||
uid: s.uid,
|
||||
gid: s.gid,
|
||||
rdev: s.rdev,
|
||||
size: s.size,
|
||||
blksize: s.blksize,
|
||||
blocks: s.blocks,
|
||||
atime: TimeSpec(seconds: s.atime.seconds, nanoseconds: s.atime.nanos),
|
||||
mtime: TimeSpec(seconds: s.mtime.seconds, nanoseconds: s.mtime.nanos),
|
||||
ctime: TimeSpec(seconds: s.ctime.seconds, nanoseconds: s.ctime.nanos)
|
||||
)
|
||||
}
|
||||
|
||||
/// Unified copy control plane. Sends a CopyRequest over gRPC and processes
|
||||
/// the response stream. Data transfer happens over a separate vsock connection
|
||||
/// managed by the caller.
|
||||
///
|
||||
/// For COPY_OUT, the `onMetadata` callback is invoked when the guest sends
|
||||
/// metadata (is_archive, total_size) before data transfer begins.
|
||||
/// For COPY_IN, `onMetadata` is not called.
|
||||
public func copy(
|
||||
direction: Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction,
|
||||
guestPath: URL,
|
||||
vsockPort: UInt32,
|
||||
mode: UInt32 = 0,
|
||||
createParents: Bool = false,
|
||||
isArchive: Bool = false,
|
||||
onMetadata: @Sendable @escaping (CopyMetadata) -> Void = { _ in }
|
||||
) async throws {
|
||||
let request = Com_Apple_Containerization_Sandbox_V3_CopyRequest.with {
|
||||
$0.direction = direction
|
||||
$0.path = guestPath.path
|
||||
$0.mode = mode
|
||||
$0.createParents = createParents
|
||||
$0.vsockPort = vsockPort
|
||||
$0.isArchive = isArchive
|
||||
}
|
||||
|
||||
try await client.copy(
|
||||
request,
|
||||
onResponse: { stream in
|
||||
for try await response in stream.messages {
|
||||
if !response.error.isEmpty {
|
||||
throw ContainerizationError(.internalError, message: "copy: \(response.error)")
|
||||
}
|
||||
switch response.status {
|
||||
case .metadata:
|
||||
onMetadata(CopyMetadata(isArchive: response.isArchive, totalSize: response.totalSize))
|
||||
case .complete:
|
||||
break
|
||||
case .UNRECOGNIZED(let value):
|
||||
throw ContainerizationError(.internalError, message: "copy: unrecognized response status \(value)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension Hosts {
|
||||
func toAgentHostsRequest(location: String) -> Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest {
|
||||
Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.with {
|
||||
$0.location = location
|
||||
if let comment {
|
||||
$0.comment = comment
|
||||
}
|
||||
$0.entries = entries.map {
|
||||
let entry = $0
|
||||
return Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry.with {
|
||||
if let comment = entry.comment {
|
||||
$0.comment = comment
|
||||
}
|
||||
$0.ipAddress = entry.ipAddress
|
||||
$0.hostnames = entry.hostnames
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StatCategory {
|
||||
/// Convert StatCategory to proto enum values.
|
||||
func toProtoCategories() -> [Com_Apple_Containerization_Sandbox_V3_StatCategory] {
|
||||
var categories: [Com_Apple_Containerization_Sandbox_V3_StatCategory] = []
|
||||
if contains(.process) {
|
||||
categories.append(.process)
|
||||
}
|
||||
if contains(.memory) {
|
||||
categories.append(.memory)
|
||||
}
|
||||
if contains(.cpu) {
|
||||
categories.append(.cpu)
|
||||
}
|
||||
if contains(.blockIO) {
|
||||
categories.append(.blockIo)
|
||||
}
|
||||
if contains(.network) {
|
||||
categories.append(.network)
|
||||
}
|
||||
if contains(.memoryEvents) {
|
||||
categories.append(.memoryEvents)
|
||||
}
|
||||
return categories
|
||||
}
|
||||
}
|
||||
|
||||
extension FilesystemOperation {
|
||||
/// Convert FilesystemOperation to proto oneof value.
|
||||
fileprivate func toProtoOperation() -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation {
|
||||
switch self {
|
||||
case .freeze:
|
||||
return .freeze(.init())
|
||||
case .thaw:
|
||||
return .thaw(.init())
|
||||
case .trim:
|
||||
return .trim(
|
||||
.with {
|
||||
$0.oneShot = .init()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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(macOS)
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Virtualization
|
||||
import vmnet
|
||||
|
||||
/// A network backed by vmnet on macOS.
|
||||
@available(macOS 26.0, *)
|
||||
public struct VmnetNetwork: Network {
|
||||
private var allocator: Allocator
|
||||
// `reference` isn't used concurrently.
|
||||
nonisolated(unsafe) private let reference: vmnet_network_ref
|
||||
|
||||
/// The IPv4 subnet of this network.
|
||||
public let subnet: CIDRv4
|
||||
|
||||
/// The IPv6 prefix of this network.
|
||||
public let prefixV6: CIDRv6?
|
||||
|
||||
/// The IPv4 gateway address of this network.
|
||||
public var ipv4Gateway: IPv4Address {
|
||||
subnet.gateway
|
||||
}
|
||||
|
||||
/// The IPv6 gateway address of this network, if a prefix exists.
|
||||
public var ipv6Gateway: IPv6Address? {
|
||||
prefixV6?.gateway
|
||||
}
|
||||
|
||||
struct Allocator: Sendable {
|
||||
private let indexAllocatorV4: any AddressAllocator<UInt32>
|
||||
private let indexAllocatorV6: (any AddressAllocator<UInt32>)?
|
||||
private let cidrV4: CIDRv4
|
||||
private let cidrV6: CIDRv6?
|
||||
private var allocations: [String: (v4: UInt32, v6: UInt32?)]
|
||||
|
||||
init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws {
|
||||
self.cidrV4 = cidrV4
|
||||
self.cidrV6 = cidrV6
|
||||
self.allocations = .init()
|
||||
let v4Size = Int(cidrV4.upper.value - cidrV4.lower.value - 3)
|
||||
self.indexAllocatorV4 = try UInt32.rotatingAllocator(
|
||||
lower: cidrV4.lower.value + 2,
|
||||
size: UInt32(v4Size)
|
||||
)
|
||||
if cidrV6 != nil {
|
||||
// Independent v6 allocator. The host portion is sourced from a
|
||||
// UInt32 index regardless of prefix length, and we never need
|
||||
// more v6 entries than v4 can serve.
|
||||
self.indexAllocatorV6 = try UInt32.rotatingAllocator(
|
||||
lower: 2,
|
||||
size: UInt32(v4Size)
|
||||
)
|
||||
} else {
|
||||
self.indexAllocatorV6 = nil
|
||||
}
|
||||
}
|
||||
|
||||
mutating func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) {
|
||||
if allocations[id] != nil {
|
||||
throw ContainerizationError(.exists, message: "allocation with id \(id) already exists")
|
||||
}
|
||||
let v4Index = try indexAllocatorV4.allocate()
|
||||
let v4 = try CIDRv4(IPv4Address(v4Index), prefix: cidrV4.prefix)
|
||||
|
||||
var v6Index: UInt32? = nil
|
||||
let v6: CIDRv6?
|
||||
if let indexAllocatorV6, let cidrV6 {
|
||||
do {
|
||||
let idx = try indexAllocatorV6.allocate()
|
||||
v6Index = idx
|
||||
let v6Value = (cidrV6.address.value & cidrV6.prefix.prefixMask128) | UInt128(idx)
|
||||
v6 = try CIDRv6(IPv6Address(v6Value), prefix: cidrV6.prefix)
|
||||
} catch {
|
||||
// Roll back v4 so the pair stays atomic.
|
||||
try? indexAllocatorV4.release(v4Index)
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
v6 = nil
|
||||
}
|
||||
|
||||
allocations[id] = (v4: v4Index, v6: v6Index)
|
||||
return (v4, v6)
|
||||
}
|
||||
|
||||
mutating func release(_ id: String) throws {
|
||||
if let entry = self.allocations[id] {
|
||||
try indexAllocatorV4.release(entry.v4)
|
||||
if let v6Index = entry.v6 {
|
||||
try indexAllocatorV6?.release(v6Index)
|
||||
}
|
||||
allocations.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A network interface supporting the vmnet_network_ref.
|
||||
public struct Interface: Containerization.Interface, VZInterface, Sendable {
|
||||
public let ipv4Address: CIDRv4
|
||||
public let ipv4Gateway: IPv4Address?
|
||||
public let ipv6Address: CIDRv6?
|
||||
public let ipv6Gateway: IPv6Address?
|
||||
public let macAddress: MACAddress?
|
||||
public let mtu: UInt32
|
||||
|
||||
// `reference` isn't used concurrently.
|
||||
nonisolated(unsafe) private let reference: vmnet_network_ref
|
||||
|
||||
public init(
|
||||
reference: vmnet_network_ref,
|
||||
ipv4Address: CIDRv4,
|
||||
ipv4Gateway: IPv4Address? = nil,
|
||||
ipv6Address: CIDRv6? = nil,
|
||||
ipv6Gateway: IPv6Address? = nil,
|
||||
macAddress: MACAddress? = nil,
|
||||
mtu: UInt32 = 1500
|
||||
) {
|
||||
self.ipv4Address = ipv4Address
|
||||
self.ipv4Gateway = ipv4Gateway
|
||||
self.ipv6Address = ipv6Address
|
||||
self.ipv6Gateway = ipv6Gateway
|
||||
self.macAddress = macAddress
|
||||
self.mtu = mtu
|
||||
self.reference = reference
|
||||
}
|
||||
|
||||
/// Returns the underlying `VZVirtioNetworkDeviceConfiguration`.
|
||||
public func device() throws -> VZVirtioNetworkDeviceConfiguration {
|
||||
let config = VZVirtioNetworkDeviceConfiguration()
|
||||
if let macAddress = self.macAddress {
|
||||
guard let mac = VZMACAddress(string: macAddress.description) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)")
|
||||
}
|
||||
config.macAddress = mac
|
||||
}
|
||||
config.attachment = VZVmnetNetworkDeviceAttachment(network: self.reference)
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new network.
|
||||
/// - Parameters:
|
||||
/// - mode: The vmnet operating mode. Defaults to `.VMNET_SHARED_MODE`.
|
||||
/// - subnetV4: The IPv4 subnet to use for this network.
|
||||
/// - prefixV6: The IPv6 prefix to use for this network.
|
||||
public init(
|
||||
mode: vmnet.operating_modes_t = .VMNET_SHARED_MODE,
|
||||
subnet: CIDRv4? = nil,
|
||||
prefixV6: CIDRv6? = nil
|
||||
) throws {
|
||||
var status: vmnet_return_t = .VMNET_FAILURE
|
||||
guard let config = vmnet_network_configuration_create(mode, &status) else {
|
||||
throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)")
|
||||
}
|
||||
|
||||
vmnet_network_configuration_disable_dhcp(config)
|
||||
|
||||
if let subnet {
|
||||
try Self.configureSubnetV4(config, subnetV4: subnet)
|
||||
}
|
||||
if let prefixV6 {
|
||||
try Self.configurePrefixV6(config, prefixV6: prefixV6)
|
||||
}
|
||||
|
||||
guard let ref = vmnet_network_create(config, &status), status == .VMNET_SUCCESS else {
|
||||
throw ContainerizationError(.unsupported, message: "failed to create vmnet network with status \(status)")
|
||||
}
|
||||
|
||||
let cidrV4 = try Self.getSubnetV4(ref)
|
||||
let cidrV6 = Self.getPrefixV6(ref)
|
||||
|
||||
self.allocator = try .init(cidrV4: cidrV4, cidrV6: cidrV6)
|
||||
self.subnet = cidrV4
|
||||
self.prefixV6 = cidrV6
|
||||
self.reference = ref
|
||||
}
|
||||
|
||||
/// Returns a new interface for use with a container. Allocates an IPv4
|
||||
/// address from the network's subnet, and — when the network has an IPv6
|
||||
/// prefix — an IPv6 address from that prefix. The two allocations are
|
||||
/// independent.
|
||||
/// - Parameter id: The container ID.
|
||||
public mutating func createInterface(_ id: String) throws -> Containerization.Interface? {
|
||||
let (v4, v6) = try allocator.allocate(id)
|
||||
return Self.Interface(
|
||||
reference: self.reference,
|
||||
ipv4Address: v4,
|
||||
ipv4Gateway: self.ipv4Gateway,
|
||||
ipv6Address: v6,
|
||||
ipv6Gateway: self.ipv6Gateway
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new interface for use with a container with a custom MTU.
|
||||
/// - Parameters:
|
||||
/// - id: The container ID.
|
||||
/// - mtu: The MTU for the interface.
|
||||
public mutating func createInterface(_ id: String, mtu: UInt32) throws -> Containerization.Interface? {
|
||||
let (v4, v6) = try allocator.allocate(id)
|
||||
return Self.Interface(
|
||||
reference: self.reference,
|
||||
ipv4Address: v4,
|
||||
ipv4Gateway: self.ipv4Gateway,
|
||||
ipv6Address: v6,
|
||||
ipv6Gateway: self.ipv6Gateway,
|
||||
mtu: mtu
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns a new interface without a default gateway route. Useful for
|
||||
/// secondary interfaces where another interface already provides the
|
||||
/// default route.
|
||||
/// - Parameter id: The container ID.
|
||||
public mutating func createInterfaceWithoutGateway(_ id: String) throws -> Containerization.Interface? {
|
||||
let (v4, v6) = try allocator.allocate(id)
|
||||
return Self.Interface(
|
||||
reference: self.reference,
|
||||
ipv4Address: v4,
|
||||
ipv6Address: v6
|
||||
)
|
||||
}
|
||||
|
||||
/// Performs cleanup of an interface.
|
||||
/// - Parameter id: The container ID.
|
||||
public mutating func releaseInterface(_ id: String) throws {
|
||||
try allocator.release(id)
|
||||
}
|
||||
|
||||
private static func getSubnetV4(_ ref: vmnet_network_ref) throws -> CIDRv4 {
|
||||
var subnet = in_addr()
|
||||
var mask = in_addr()
|
||||
vmnet_network_get_ipv4_subnet(ref, &subnet, &mask)
|
||||
|
||||
let sa = UInt32(bigEndian: subnet.s_addr)
|
||||
let mv = UInt32(bigEndian: mask.s_addr)
|
||||
|
||||
let lower = IPv4Address(sa & mv)
|
||||
let upper = IPv4Address(lower.value + ~mv)
|
||||
|
||||
return try CIDRv4(lower: lower, upper: upper)
|
||||
}
|
||||
|
||||
private static func configureSubnetV4(_ config: vmnet_network_configuration_ref, subnetV4: CIDRv4) throws {
|
||||
let gateway = subnetV4.gateway
|
||||
|
||||
var ga = in_addr()
|
||||
inet_pton(AF_INET, gateway.description, &ga)
|
||||
|
||||
let mask = IPv4Address(subnetV4.prefix.prefixMask32)
|
||||
var ma = in_addr()
|
||||
inet_pton(AF_INET, mask.description, &ma)
|
||||
|
||||
guard vmnet_network_configuration_set_ipv4_subnet(config, &ga, &ma) == .VMNET_SUCCESS else {
|
||||
throw ContainerizationError(.internalError, message: "failed to set IPv4 subnet \(subnetV4) for network")
|
||||
}
|
||||
}
|
||||
|
||||
private static func getPrefixV6(_ ref: vmnet_network_ref) -> CIDRv6? {
|
||||
var p = in6_addr()
|
||||
var len: UInt8 = 0
|
||||
vmnet_network_get_ipv6_prefix(ref, &p, &len)
|
||||
|
||||
guard len > 0, let prefix = Prefix.ipv6(len) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let bytes: [UInt8] = withUnsafeBytes(of: p) { Array($0) }
|
||||
guard let address = try? IPv6Address(bytes) else {
|
||||
return nil
|
||||
}
|
||||
return try? CIDRv6(address, prefix: prefix)
|
||||
}
|
||||
|
||||
private static func configurePrefixV6(_ config: vmnet_network_configuration_ref, prefixV6: CIDRv6) throws {
|
||||
var p = in6_addr()
|
||||
inet_pton(AF_INET6, prefixV6.lower.description, &p)
|
||||
|
||||
guard vmnet_network_configuration_set_ipv6_prefix(config, &p, prefixV6.prefix.length) == .VMNET_SUCCESS else {
|
||||
throw ContainerizationError(.internalError, message: "failed to set IPv6 prefix \(prefixV6) for network")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,76 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
|
||||
#if os(macOS)
|
||||
import Virtualization
|
||||
#endif
|
||||
|
||||
/// A stream of vsock connections.
|
||||
public final class VsockListener: NSObject, Sendable, AsyncSequence {
|
||||
public typealias Element = FileHandle
|
||||
|
||||
/// The port the connections are for.
|
||||
public let port: UInt32
|
||||
|
||||
private let connections: AsyncStream<FileHandle>
|
||||
private let cont: AsyncStream<FileHandle>.Continuation
|
||||
private let stopListening: @Sendable (_ port: UInt32) throws -> Void
|
||||
|
||||
package init(port: UInt32, stopListen: @Sendable @escaping (_ port: UInt32) throws -> Void) {
|
||||
self.port = port
|
||||
let (stream, continuation) = AsyncStream.makeStream(of: FileHandle.self)
|
||||
self.connections = stream
|
||||
self.cont = continuation
|
||||
self.stopListening = stopListen
|
||||
}
|
||||
|
||||
public func finish() throws {
|
||||
self.cont.finish()
|
||||
try self.stopListening(self.port)
|
||||
}
|
||||
|
||||
public func makeAsyncIterator() -> AsyncStream<FileHandle>.AsyncIterator {
|
||||
connections.makeAsyncIterator()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
extension VsockListener: VZVirtioSocketListenerDelegate {
|
||||
public func listener(
|
||||
_: VZVirtioSocketListener, shouldAcceptNewConnection conn: VZVirtioSocketConnection,
|
||||
from _: VZVirtioSocketDevice
|
||||
) -> Bool {
|
||||
let fd = dup(conn.fileDescriptor)
|
||||
guard fd != -1 else {
|
||||
return false
|
||||
}
|
||||
conn.close()
|
||||
|
||||
let fh = FileHandle(fileDescriptor: fd, closeOnDealloc: false)
|
||||
let result = cont.yield(fh)
|
||||
if case .terminated = result {
|
||||
try? fh.close()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user