diff --git a/Makefile b/Makefile index c79d6995d..3d01b93a1 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ LIBARCHIVE_UPSTREAM_REPO := https://github.com/libarchive/libarchive LIBARCHIVE_UPSTREAM_VERSION := v3.7.7 LIBARCHIVE_LOCAL_DIR := workdir/libarchive -KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz +KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst CLOUD_HYPERVISOR_URL := https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/v52.0/cloud-hypervisor-static-aarch64 # SHA256 of the v52.0 aarch64 static binary (verified locally from the # upstream release artifact). Bump alongside CLOUD_HYPERVISOR_URL. @@ -410,11 +410,11 @@ integration: .PHONY: fetch-default-kernel fetch-default-kernel: @mkdir -p .local/ bin/ -ifeq (,$(wildcard .local/kata.tar.gz)) - @curl -SsL -o .local/kata.tar.gz ${KATA_BINARY_PACKAGE} +ifeq (,$(wildcard .local/kata.tar)) + @curl -SsL -o .local/kata.tar ${KATA_BINARY_PACKAGE} endif ifeq (,$(wildcard .local/vmlinux-$(KERNEL_ARCH))) - @tar -zxf .local/kata.tar.gz -C .local/ --strip-components=1 + @tar -xf .local/kata.tar -C .local/ --strip-components=1 @cp -L .local/opt/kata/share/kata-containers/vmlinux.container .local/vmlinux-$(KERNEL_ARCH) endif ifeq (,$(wildcard bin/vmlinux-$(KERNEL_ARCH))) diff --git a/README.md b/README.md index f658b08dd..372a872a1 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,16 @@ After building, run basic and integration tests: make test integration ``` +Tests that only apply to Linux are compiled out on macOS, so `make test` passes +without running them. Run those with: + +```bash +make linux-test +``` + +which runs the same tests inside the Linux dev container, as the Linux build +workflow does. + A kernel is required to run integration tests. If you do not have a kernel locally, a default kernel can be fetched using the `make fetch-default-kernel` target. diff --git a/Sources/CShim/include/swap.h b/Sources/CShim/include/swap.h new file mode 100644 index 000000000..f16000ee0 --- /dev/null +++ b/Sources/CShim/include/swap.h @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef __SWAP_H +#define __SWAP_H + +#if defined(__linux__) + +// swapon(2) lives in , which Swift's glibc modulemap does not +// carry, so it is reachable from Swift only through a wrapper. + +// Discard the whole area when it is enabled, and each page as it is freed. +// These come from the same header, and SWAP_FLAG_DISCARD_PAGES has no UAPI +// header of its own at all, so every consumer declares it; see util-linux +// sys-utils/swapon.c. +#define CZ_SWAP_DISCARD 0x10000 +#define CZ_SWAP_DISCARD_PAGES 0x40000 + +int CZ_swapon(const char *path, int flags); + +#endif + +#endif diff --git a/Sources/CShim/swap.c b/Sources/CShim/swap.c new file mode 100644 index 000000000..2a66461dc --- /dev/null +++ b/Sources/CShim/swap.c @@ -0,0 +1,23 @@ +/* + * 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. + */ + +#ifdef __linux__ +#include + +#include "swap.h" + +int CZ_swapon(const char *path, int flags) { return swapon(path, flags); } +#endif diff --git a/Sources/Containerization/CHHotplugProvider.swift b/Sources/Containerization/CHHotplugProvider.swift index 870fb1ffb..380cba765 100644 --- a/Sources/Containerization/CHHotplugProvider.swift +++ b/Sources/Containerization/CHHotplugProvider.swift @@ -27,8 +27,8 @@ import Synchronization /// /// Handles both block (`vm.add-disk`) and virtiofs (`vm.add-fs`, with one /// `virtiofsd` per unique source-hash tag) hotplug, plus the matching -/// `vm.remove-device` teardown. Owns the per-VM mount registry so -/// `CHVirtualMachineInstance.mounts` can forward to it. +/// `vm.remove-device` teardown. Owns the machine's storage so +/// `CHVirtualMachineInstance.storage` can forward to it. final class CHHotplugProvider: HotplugProvider { struct HotplugRecord: Sendable { let chDeviceId: String @@ -50,7 +50,7 @@ final class CHHotplugProvider: HotplugProvider { private let workDir: URL private let virtiofsdBinaryOverride: URL? private let allocator: any AddressAllocator - private let _mounts: Mutex<[String: [AttachedFilesystem]]> + private let _storage: Mutex private let _records: Mutex<[String: [HotplugRecord]]> private let _tags: Mutex<[String: VirtiofsdTagState]> /// Serializes per-tag virtiofsd spawn so a concurrent hotplug for the @@ -65,14 +65,14 @@ final class CHHotplugProvider: HotplugProvider { workDir: URL, virtiofsdBinary: URL?, allocator: any AddressAllocator, - initialMounts: [String: [AttachedFilesystem]], + initialStorage: MachineAttachments, logger: Logger? ) { self.client = client self.workDir = workDir self.virtiofsdBinaryOverride = virtiofsdBinary self.allocator = allocator - self._mounts = Mutex(initialMounts) + self._storage = Mutex(initialStorage) self._records = Mutex([:]) self._tags = Mutex([:]) self.spawnLock = AsyncLock() @@ -81,14 +81,14 @@ final class CHHotplugProvider: HotplugProvider { // MARK: - Read accessors - var mounts: [String: [AttachedFilesystem]] { - _mounts.withLock { $0 } + var storage: MachineAttachments { + _storage.withLock { $0 } } - func withMountRegistry( - _ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T + func withStorage( + _ body: (inout sending MachineAttachments) throws -> sending T ) rethrows -> T { - try _mounts.withLock(body) + try _storage.withLock(body) } // MARK: - HotplugProvider conformance @@ -151,12 +151,13 @@ final class CHHotplugProvider: HotplugProvider { } func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { - var attached: [AttachedFilesystem] = [rootfs] + var mounts: [AttachedFilesystem] = [] for mount in additionalMounts { - attached.append(try AttachedFilesystem(mount: mount, allocator: allocator)) + mounts.append(try AttachedFilesystem(mount: mount, allocator: allocator)) } - _mounts.withLock { - $0[id, default: []].append(contentsOf: attached) + let container = ContainerAttachments(rootfs: rootfs, mounts: mounts) + _storage.withLock { + $0.containers[id] = container } } @@ -190,19 +191,10 @@ final class CHHotplugProvider: HotplugProvider { } } - // Drop block-derived AttachedFilesystem entries for `id`. Block entries - // are the ones whose source was rewritten to "/dev/vd" by - // `hotplug(_:)` (or by AttachedFilesystem(mount:allocator:) for an - // additionalMount of type virtio-blk). - _mounts.withLock { state in - guard var perID = state[id] else { return } - perID.removeAll { $0.source.hasPrefix("/dev/vd") } - if perID.isEmpty { - state.removeValue(forKey: id) - } else { - state[id] = perID - } - } + // The container's devices are gone, so the container leaves the + // registry with them; its shares are released separately and their + // processes reference-counted through `_tags`. + _ = _storage.withLock { $0.containers.removeValue(forKey: id) } } func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { @@ -226,7 +218,7 @@ final class CHHotplugProvider: HotplugProvider { let chDeviceId = try await ensureVirtiofsDevice(tag: tag, source: source, readonly: readonly) // Record once per tag for this container. The AttachedFilesystem // entries for these mounts are written by registerMounts (the sole - // _mounts writer), so we do NOT touch _mounts here. + // registry writer), so we do NOT touch the storage here. _records.withLock { $0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag))) } @@ -338,16 +330,17 @@ final class CHHotplugProvider: HotplugProvider { try? FileManager.default.removeItem(at: socket) } - // Drop virtiofs AttachedFilesystem entries for `id`. AttachedFilesystem - // sets `type = mount.type` which for a `.virtiofs` mount is "virtiofs". - _mounts.withLock { state in - guard var perID = state[id] else { return } - perID.removeAll { $0.type == "virtiofs" } - if perID.isEmpty { - state.removeValue(forKey: id) - } else { - state[id] = perID + // Drop the container's virtiofs entries. A container whose rootfs is + // itself a share leaves the registry whole; one that keeps block + // devices keeps its entry with the share entries dropped. + _storage.withLock { state in + guard var container = state.containers[id] else { return } + if container.rootfs.type == "virtiofs" { + state.containers.removeValue(forKey: id) + return } + container.mounts.removeAll { $0.type == "virtiofs" } + state.containers[id] = container } } @@ -358,15 +351,21 @@ final class CHHotplugProvider: HotplugProvider { /// is the user-supplied `FsConfig.id` (which `vm.remove-device` keys on). /// `ownerIds` are the container ids that count toward this tag's refcount; /// each gets a `HotplugRecord` so `releaseVirtioFS(id:)` walks them - /// uniformly. + /// uniformly. `machineHeld` adds one reference nothing releases, for a + /// share the machine itself owns (a volume). func recordBootTimeVirtiofs( tag: String, process: VirtiofsdProcess, chDeviceId: String, - ownerIds: [String] + ownerIds: [String], + machineHeld: Bool ) { _tags.withLock { - $0[tag] = VirtiofsdTagState(process: process, refcount: ownerIds.count, chDeviceId: chDeviceId) + $0[tag] = VirtiofsdTagState( + process: process, + refcount: ownerIds.count + (machineHeld ? 1 : 0), + chDeviceId: chDeviceId + ) } _records.withLock { records in for id in ownerIds { diff --git a/Sources/Containerization/CHVirtualMachineInstance.swift b/Sources/Containerization/CHVirtualMachineInstance.swift index 122f592a2..e363d5cac 100644 --- a/Sources/Containerization/CHVirtualMachineInstance.swift +++ b/Sources/Containerization/CHVirtualMachineInstance.swift @@ -46,7 +46,7 @@ public final class CHVirtualMachineInstance: Sendable { public struct Configuration: Sendable { public var cpus: Int public var memoryInBytes: UInt64 - public var mountsByID: [String: [Mount]] + public var storage: MachineMounts public var interfaces: [any Interface] public var kernel: Kernel? public var initialFilesystem: Mount? @@ -56,7 +56,7 @@ public final class CHVirtualMachineInstance: Sendable { public init() { self.cpus = 4 self.memoryInBytes = 1024 * 1024 * 1024 - self.mountsByID = [:] + self.storage = MachineMounts() self.interfaces = [] } } @@ -65,8 +65,8 @@ public final class CHVirtualMachineInstance: Sendable { /// `start()`'s `VmConfig.disks` ordering matches the allocator letters. struct BootDisk: Sendable { let mount: Mount - let containerId: String? // nil for rootfs - let letter: Character + let chId: String + let readonly: Bool } // MARK: - State @@ -76,8 +76,8 @@ public final class CHVirtualMachineInstance: Sendable { _state.withLock { $0 } } - public var mounts: [String: [AttachedFilesystem]] { - hotplug.mounts + public var storage: MachineAttachments { + hotplug.storage } /// Cloud-hypervisor exposes one virtio-fs device per source-hash tag, so @@ -167,9 +167,9 @@ public final class CHVirtualMachineInstance: Sendable { ) self.workDir = workDir - // 2. Block allocator + boot inventory. Walks rootfs first, then - // mountsByID sorted by container id, allocating disk letters in - // that order. The same allocator is later handed to the hotplug + // 2. Block allocator + boot inventory. Walks rootfs first, then the + // machine's storage in its device order, allocating disk letters + // as it goes. The same allocator is later handed to the hotplug // provider so runtime add-disk picks up where boot wiring left off. let allocator = Character.blockDeviceTagAllocator() let inventory = try config.bootInventory(allocator: allocator) @@ -201,14 +201,14 @@ public final class CHVirtualMachineInstance: Sendable { logger: logger ?? Logger(label: "CloudHypervisor.Client") ) - // 5. Hotplug provider — owns the mount registry, seeded with the + // 5. Hotplug provider — owns the machine's storage, seeded with the // boot inventory so registerMounts can append to it. self.hotplug = CHHotplugProvider( client: self.client, workDir: workDir, virtiofsdBinary: virtiofsdBinary, allocator: allocator, - initialMounts: inventory.attachments, + initialStorage: inventory.attachments, logger: logger ) @@ -222,11 +222,11 @@ public final class CHVirtualMachineInstance: Sendable { self._stdioPool = Mutex([:]) } - /// Mutate the mount registry. Forwards to the hotplug provider, which + /// Mutate the storage registry. Forwards to the hotplug provider, which /// owns the registry. Kept on the instance for parity with the macOS - /// path's `withMountRegistry` API. - func withMountRegistry(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { - try hotplug.withMountRegistry(body) + /// path's `withStorage` API. + func withStorage(_ body: (inout sending MachineAttachments) throws -> sending T) rethrows -> T { + try hotplug.withStorage(body) } } @@ -510,34 +510,46 @@ extension CHVirtualMachineInstance { throw ContainerizationError(.invalidArgument, message: "initialFilesystem is required for cloud-hypervisor backend") } - // Disks: rootfs forced read-only at the device level; container disks + // Disks: rootfs forced read-only at the device level; other disks // honor their `ro` option through chDiskConfig. var disks: [CloudHypervisor.DiskConfig] = [] for bd in bootDisks { - let chId = bd.containerId.map { "blk-\($0)-\(bd.letter)" } ?? "rootfs" - if var disk = bd.mount.chDiskConfig(id: chId) { - if bd.containerId == nil { + if var disk = bd.mount.chDiskConfig(id: bd.chId) { + if bd.readonly { disk.readonly = true } disks.append(disk) } } - // Virtiofs: group all .virtiofs mounts in mountsByID by source-hash - // tag, spawn one virtiofsd per tag, build matching FsConfigs. - var byTag: [String: (mounts: [Mount], owners: [String])] = [:] - for cid in config.mountsByID.keys.sorted() { - guard let mounts = config.mountsByID[cid] else { continue } - for mount in mounts { - guard case .virtiofs = mount.runtimeOptions else { continue } - let tag = try hashFilePath(path: mount.source) - var entry = byTag[tag] ?? (mounts: [], owners: []) - entry.mounts.append(mount) - if !entry.owners.contains(cid) { - entry.owners.append(cid) + // Virtiofs: group all virtiofs mounts in the machine's storage by + // source-hash tag, spawn one virtiofsd per tag, build matching + // FsConfigs. A volume's tag is the machine's own, held for its + // lifetime. + var byTag: [String: (mounts: [Mount], owners: [String], machineHeld: Bool)] = [:] + func groupVirtiofs(_ mount: Mount, owner: String?) throws { + guard case .virtiofs = mount.runtimeOptions else { return } + let tag = try hashFilePath(path: mount.source) + var entry = byTag[tag] ?? (mounts: [], owners: [], machineHeld: false) + entry.mounts.append(mount) + if let owner { + if !entry.owners.contains(owner) { + entry.owners.append(owner) } - byTag[tag] = entry + } else { + entry.machineHeld = true } + byTag[tag] = entry + } + for cid in config.storage.containers.keys.sorted() { + guard let container = config.storage.containers[cid] else { continue } + for mount in container.all { + try groupVirtiofs(mount, owner: cid) + } + } + for name in config.storage.volumes.keys.sorted() { + guard let mount = config.storage.volumes[name] else { continue } + try groupVirtiofs(mount, owner: nil) } var fsConfigs: [CloudHypervisor.FsConfig] = [] @@ -569,7 +581,8 @@ extension CHVirtualMachineInstance { tag: tag, process: process, chDeviceId: chDeviceId, - ownerIds: entry.owners + ownerIds: entry.owners, + machineHeld: entry.machineHeld ) fsConfigs.append( @@ -714,40 +727,46 @@ extension CHVirtualMachineInstance { // MARK: - Boot inventory extension CHVirtualMachineInstance.Configuration { - /// Walks boot-time mounts in deterministic order (rootfs first, then - /// `mountsByID` sorted by container id, then each container's mounts in - /// input order), allocating disk letters for virtio-blk mounts and seeding - /// the per-container `AttachedFilesystem` registry. + /// Walks boot-time storage in the machine's device order (rootfs first, + /// then containers sorted by id, each in role order, then volumes sorted + /// by name), allocating disk letters for virtio-blk mounts and seeding + /// the machine's `AttachedFilesystem` registry. /// /// The allocator is shared with the runtime hotplug provider, so block /// hotplug picks up at the next free letter after boot. func bootInventory( allocator: any AddressAllocator - ) throws -> (attachments: [String: [AttachedFilesystem]], bootDisks: [CHVirtualMachineInstance.BootDisk]) { + ) throws -> (attachments: MachineAttachments, bootDisks: [CHVirtualMachineInstance.BootDisk]) { var bootDisks: [CHVirtualMachineInstance.BootDisk] = [] - var attachments: [String: [AttachedFilesystem]] = [:] - // Rootfs is not part of mountsByID. If it's a block device, it claims - // the first letter (vda) so the kernel cmdline `root=/dev/vda` is right. + // The rootfs is the machine's own, not part of its containers' + // storage. If it's a block device, it claims the first letter (vda) + // so the kernel cmdline `root=/dev/vda` is right. if let rootfs = self.initialFilesystem, rootfs.isBlock { - let letter = try allocator.allocate() - bootDisks.append(.init(mount: rootfs, containerId: nil, letter: letter)) + _ = try allocator.allocate() + bootDisks.append(.init(mount: rootfs, chId: "rootfs", readonly: true)) } - for cid in self.mountsByID.keys.sorted() { - guard let mounts = self.mountsByID[cid] else { continue } - var perContainer: [AttachedFilesystem] = [] - for mount in mounts { - let attached = try AttachedFilesystem(mount: mount, allocator: allocator) - if mount.isBlock, let letter = attached.source.last { - bootDisks.append(.init(mount: mount, containerId: cid, letter: letter)) - } - perContainer.append(attached) + func attach(_ mount: Mount, chId: (Character) -> String) throws -> AttachedFilesystem { + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + if mount.isBlock, let letter = attached.source.last { + bootDisks.append(.init(mount: mount, chId: chId(letter), readonly: false)) } - attachments[cid] = perContainer + return attached + } + + var containers: [String: ContainerAttachments] = [:] + for cid in self.storage.containers.keys.sorted() { + guard let container = self.storage.containers[cid] else { continue } + containers[cid] = try container.map { try attach($0, chId: { "blk-\(cid)-\($0)" }) } + } + var volumes: [String: AttachedFilesystem] = [:] + for name in self.storage.volumes.keys.sorted() { + guard let mount = self.storage.volumes[name] else { continue } + volumes[name] = try attach(mount, chId: { "vol-\(name)-\($0)" }) } - return (attachments, bootDisks) + return (MachineAttachments(containers: containers, volumes: volumes), bootDisks) } } #endif diff --git a/Sources/Containerization/CHVirtualMachineManager.swift b/Sources/Containerization/CHVirtualMachineManager.swift index ba20b5d1d..c444620d0 100644 --- a/Sources/Containerization/CHVirtualMachineManager.swift +++ b/Sources/Containerization/CHVirtualMachineManager.swift @@ -99,7 +99,7 @@ public struct CHVirtualMachineManager: VirtualMachineManager { instanceConfig.cpus = vmConfig.cpus instanceConfig.memoryInBytes = vmConfig.memoryInBytes instanceConfig.interfaces = vmConfig.interfaces - instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.storage = vmConfig.storage instanceConfig.bootLog = vmConfig.bootLog instanceConfig.extensions = vmConfig.extensions instanceConfig.kernel = kernel diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 27e9fbe47..a1b082e18 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -195,6 +195,9 @@ public struct ContainerManager: Sendable { /// - 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. + /// - swapSizeInBytes: Optional size for a swap area. When provided, a raw block + /// device of this size is created and the guest enables it as swap, so a + /// workload exceeding its memory limit reclaims rather than being killed. /// - 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. @@ -203,6 +206,7 @@ public struct ContainerManager: Sendable { reference: String, rootfsSizeInBytes: UInt64 = 8.gib(), writableLayerSizeInBytes: UInt64? = nil, + swapSizeInBytes: UInt64? = nil, readOnly: Bool = false, networking: Bool = true, progress: ProgressHandler? = nil, @@ -214,6 +218,7 @@ public struct ContainerManager: Sendable { image: image, rootfsSizeInBytes: rootfsSizeInBytes, writableLayerSizeInBytes: writableLayerSizeInBytes, + swapSizeInBytes: swapSizeInBytes, readOnly: readOnly, networking: networking, progress: progress, @@ -229,6 +234,9 @@ public struct ContainerManager: Sendable { /// - 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. + /// - swapSizeInBytes: Optional size for a swap area. When provided, a raw block + /// device of this size is created and the guest enables it as swap, so a + /// workload exceeding its memory limit reclaims rather than being killed. /// - 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. @@ -237,6 +245,7 @@ public struct ContainerManager: Sendable { image: Image, rootfsSizeInBytes: UInt64 = 8.gib(), writableLayerSizeInBytes: UInt64? = nil, + swapSizeInBytes: UInt64? = nil, readOnly: Bool = false, networking: Bool = true, progress: ProgressHandler? = nil, @@ -263,14 +272,25 @@ public struct ContainerManager: Sendable { ) } + // Create the swap device if a size is specified. + var swapLayer: Mount? = nil + if let swapSize = swapSizeInBytes, swapSize > 0 { + swapLayer = try createSwapDevice( + at: path.appendingPathComponent("swap.raw"), + size: swapSize + ) + } + return try await create( id, image: image, rootfs: rootfs, writableLayer: writableLayer, - networking: networking, - configuration: configuration - ) + networking: networking + ) { config in + config.swapLayer = swapLayer + try configuration(&config) + } } /// Returns a new container from the provided image and root filesystem mount. @@ -357,6 +377,37 @@ public struct ContainerManager: Sendable { } } + /// Create a raw block file to back a container's swap area. + /// + /// It carries no filesystem: the agent writes the swap header to the device + /// and enables it. The file is sparse, so it costs the host only the pages + /// the guest has actually swapped out, and gives them back on discard. A + /// swap area held in a file has to be free of holes, since the kernel walks + /// its extents; the guest reaches this one as a block device, which the + /// kernel takes as a single extent without consulting the host's layout. + /// https://github.com/torvalds/linux/blob/master/mm/swapfile.c + private func createSwapDevice(at destination: URL, size: UInt64) throws -> Mount { + let path = destination.absolutePath() + guard !FileManager.default.fileExists(atPath: path) else { + throw ContainerizationError(.exists, message: "swap device already exists at \(path)") + } + guard FileManager.default.createFile(atPath: path, contents: nil) else { + throw ContainerizationError(.internalError, message: "failed to create swap device at \(path)") + } + let handle = try FileHandle(forWritingTo: destination) + defer { try? handle.close() } + try handle.truncate(atOffset: size) + // A swap area holds nothing that outlives the container, so the host + // has no reason to synchronize it to permanent storage. + return .block( + format: Swap.mountType, + source: path, + destination: "", + options: [], + runtimeOptions: ["vzDiskImageSynchronizationMode=none"] + ) + } + private func createEmptyFilesystem(at destination: URL, size: UInt64) throws -> Mount { let path = destination.absolutePath() guard !FileManager.default.fileExists(atPath: path) else { diff --git a/Sources/Containerization/ContainerStorage.swift b/Sources/Containerization/ContainerStorage.swift new file mode 100644 index 000000000..49fae6d48 --- /dev/null +++ b/Sources/Containerization/ContainerStorage.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// 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 container's storage, by role. +/// +/// The same shape describes storage at both stages of its life: as the +/// `Mount` values a container is configured with, and as the +/// `AttachedFilesystem` values the machine reports once they are attached. +/// Converting between the two is a `map` over the structure, so the roles +/// cannot drift apart between configuration and attachment. +public struct ContainerStorage: Sendable { + /// The container's root filesystem. + public var rootfs: Value + + /// The writable layer mounted over the rootfs as the upper side of an + /// overlay, when the container has one. + public var writableLayer: Value? + + /// The swap area enabled for the container, when it has one. + public var swap: Value? + + /// The container's remaining mounts, in configuration order. + public var mounts: [Value] + + public init(rootfs: Value, writableLayer: Value? = nil, swap: Value? = nil, mounts: [Value] = []) { + self.rootfs = rootfs + self.writableLayer = writableLayer + self.swap = swap + self.mounts = mounts + } + + /// Every value in the structure: the rootfs, the writable layer and swap + /// when present, then the mounts, in that order. + public var all: [Value] { + var values = [rootfs] + if let writableLayer { + values.append(writableLayer) + } + if let swap { + values.append(swap) + } + values.append(contentsOf: mounts) + return values + } + + /// The storage with `transform` applied to every value, each keeping + /// its role. + public func map(_ transform: (Value) throws -> U) rethrows -> ContainerStorage { + ContainerStorage( + rootfs: try transform(rootfs), + writableLayer: try writableLayer.map(transform), + swap: try swap.map(transform), + mounts: try mounts.map(transform) + ) + } +} + +/// A container's storage as configured, before its machine exists. +public typealias ContainerMounts = ContainerStorage + +/// A container's storage as attached to its machine. +public typealias ContainerAttachments = ContainerStorage + +/// The storage a machine carries: each container's, and the resources its +/// containers share. +public struct MachineStorage: Sendable { + /// Each container's storage, by container ID. + public var containers: [String: ContainerStorage] + + /// Volumes shared by the machine's containers, by volume name. + public var volumes: [String: Value] + + public init(containers: [String: ContainerStorage] = [:], volumes: [String: Value] = [:]) { + self.containers = containers + self.volumes = volumes + } + + /// Every value the machine carries: containers sorted by ID, each in + /// role order, then volumes sorted by name. Walks that allocate device + /// addresses and walks that create the devices use this one order, so an + /// address always names the device it was handed out for. + public var ordered: [Value] { + containers.keys.sorted().flatMap { containers[$0]?.all ?? [] } + + volumes.keys.sorted().compactMap { volumes[$0] } + } + + /// The storage with `transform` applied to every value, each keeping its + /// place. The transform runs in no particular order, so an allocating + /// conversion walks the fields sorted itself. + public func map(_ transform: (Value) throws -> U) rethrows -> MachineStorage { + MachineStorage( + containers: try containers.mapValues { try $0.map(transform) }, + volumes: try volumes.mapValues(transform) + ) + } +} + +/// A machine's storage as configured, before it exists. +public typealias MachineMounts = MachineStorage + +/// A machine's storage as attached. +public typealias MachineAttachments = MachineStorage diff --git a/Sources/Containerization/HotplugProvider.swift b/Sources/Containerization/HotplugProvider.swift index f535ce3f0..0835077f4 100644 --- a/Sources/Containerization/HotplugProvider.swift +++ b/Sources/Containerization/HotplugProvider.swift @@ -26,7 +26,7 @@ public protocol HotplugProvider: Sendable { /// - 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. + /// Register mounts for a container in the machine's storage. /// - Parameters: /// - id: The container ID /// - rootfs: The rootfs attachment from hotplug diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 34964fcdc..4d5289a73 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -99,6 +99,16 @@ public final class LinuxContainer: Container, Sendable { /// The total is aligned to a 1 MiB boundary. public var memoryOverhead: UInt64 = 128.mib() + /// Optional swap area for the container, as a block device mount. + /// + /// Swap lets a workload whose resident set exceeds `memoryInBytes` + /// reclaim rather than meet the out of memory killer. The area is a + /// block device rather than a file in the guest, because no filesystem + /// the agent can write to exists when the sandbox starts: its root is + /// mounted read only. The `destination` field is ignored, as the area + /// is enabled rather than mounted. + public var swapLayer: Mount? = nil + public init() {} public init( @@ -536,25 +546,39 @@ extension LinuxContainer { config.interfaces } + /// Enable the container's swap area, if it has one. + /// + /// The device is attached with the container's other block devices, so the + /// agent is told the guest path the VMM allocated it. It is enabled rather + /// than mounted, which is why it travels as a mount of type `swap`. + private func enableSwap( + attached: ContainerAttachments, + agent: VirtualMachineAgent + ) async throws { + guard self.config.swapLayer != nil else { + return + } + guard let swap = attached.swap else { + throw ContainerizationError(.notFound, message: "swap mount not found") + } + try await agent.mount( + ContainerizationOCI.Mount( + type: Swap.mountType, + source: swap.source, + destination: "", + options: swap.options + )) + } + private func mountRootfs( - attachments: [AttachedFilesystem], + attached: ContainerAttachments, rootfsPath: String, agent: VirtualMachineAgent ) async throws { - guard let rootfsAttachment = attachments.first else { - throw ContainerizationError(.notFound, message: "rootfs mount not found") - } + let rootfsAttachment = attached.rootfs - if self.writableLayer != nil { + if let writableAttachment = attached.writableLayer { // Set up overlayfs with image as lower layer and writable layer as upper. - guard attachments.count >= 2 else { - throw ContainerizationError( - .notFound, - message: "writable layer mount not found" - ) - } - let writableAttachment = attachments[1] - let lowerPath = "/run/container/\(self.id)/lower" let upperMountPath = "/run/container/\(self.id)/upper" let upperPath = "/run/container/\(self.id)/upper/diff" @@ -623,17 +647,21 @@ extension LinuxContainer { // This is dumb, but alas. let fileMountContextHolder = Mutex(fileMountContext) - // Build the list of mounts to attach to the VM. - var containerMounts = [modifiedRootfs] + fileMountContext.transformedMounts - if let writableLayer = self.writableLayer { - containerMounts.insert(writableLayer, at: 1) - } + // Build the container's storage to attach to the VM. The swap + // device is attached with the container's other block devices so + // the guest is told the /dev path the VMM allocates it. + let containerStorage = ContainerMounts( + rootfs: modifiedRootfs, + writableLayer: self.writableLayer, + swap: self.config.swapLayer, + mounts: fileMountContext.transformedMounts + ) let vmConfig = VMConfiguration( cpus: vmCpus, memoryInBytes: vmMemory, interfaces: self.interfaces, - mountsByID: [self.id: containerMounts], + storage: MachineMounts(containers: [self.id: containerStorage]), bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -643,7 +671,7 @@ extension LinuxContainer { do { try await vm.start() - let mountsForAgent = containerMounts + let storageForAgent = containerStorage try await vm.withAgent { agent in try await agent.standardSetup() @@ -656,7 +684,7 @@ extension LinuxContainer { // with zero shares), but the cloud-hypervisor backend // only spawns virtiofsd when shares exist; mounting an // unbacked tag fails with EINVAL. - let hasVirtiofsMount = mountsForAgent.contains { mount in + let hasVirtiofsMount = storageForAgent.all.contains { mount in if case .virtiofs = mount.runtimeOptions { return true } return false } @@ -671,7 +699,7 @@ extension LinuxContainer { // gets populated. if vm.virtiofsLayout == .perTag { try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) - let virtiofsAttachments = (vm.mounts[self.id] ?? []).filter { $0.type == "virtiofs" } + let virtiofsAttachments = (vm.storage.containers[self.id]?.all ?? []).filter { $0.type == "virtiofs" } let uniqueTags = Set(virtiofsAttachments.map(\.source)) for tag in uniqueTags { let dest = "/run/virtiofs/\(tag)" @@ -695,18 +723,18 @@ extension LinuxContainer { } } - guard let attachments = vm.mounts[self.id] else { + guard let attached = vm.storage.containers[self.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found") } let rootfsPath = Self.guestRootfsPath(self.id) - try await self.mountRootfs(attachments: attachments, rootfsPath: rootfsPath, agent: agent) + try await self.mountRootfs(attached: attached, rootfsPath: rootfsPath, agent: agent) + try await self.enableSwap(attached: attached, agent: agent) // Mount file mount holding directories under /run. if fileMountContext.hasFileMounts { - let containerMounts = vm.mounts[self.id] ?? [] var ctx = fileMountContextHolder.withLock { $0 } try await ctx.mountHoldingDirectories( - vmMounts: containerMounts, + vmMounts: attached.mounts, agent: agent ) fileMountContextHolder.withLock { $0 = ctx } @@ -764,15 +792,12 @@ extension LinuxContainer { let agent = try await createdState.vm.dialAgent() do { var spec = self.generateRuntimeSpec() - // We don't need the rootfs (or writable layer), nor do OCI runtimes want it included. - // Also filter out file mount holding directories. We'll mount those separately under /run. + // Filter out file mount holding directories. We'll mount those separately under /run. // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} - let containerMounts = createdState.vm.mounts[self.id] ?? [] + let containerMounts = createdState.vm.storage.containers[self.id]?.mounts ?? [] let holdingTags = createdState.fileMountContext.holdingDirectoryTags - // Drop rootfs, and writable layer if present. - let mountsToSkip = self.writableLayer != nil ? 2 : 1 var mounts: [ContainerizationOCI.Mount] = - containerMounts.dropFirst(mountsToSkip) + containerMounts .filter { !holdingTags.contains($0.source) } .map { attached -> ContainerizationOCI.Mount in if attached.type == "virtiofs" { diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 6275a4d49..fa4d1785e 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -446,24 +446,28 @@ extension LinuxPod { // the container's bind mounts from /run/virtiofs/ fail // with ENOENT. // - // Derive the tags from the additional mounts directly rather - // than from vm.mounts[id], so this is independent of the - // rootfs (which may be virtiofs or virtio-blk) and of mount - // ordering. The rootfs is mounted at /run/container//rootfs - // and is never consumed from /run/virtiofs. + // Derive the new tags from the additional mounts being added; + // the machine's storage names what is already shared. let newVirtiofsTags = try virtioFSMounts.map { try hashFilePath(path: $0.source) } if !newVirtiofsTags.isEmpty { try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) if vm.virtiofsLayout == .perTag { // Tags already mounted in the guest at boot or by a - // prior hotplug (i.e. present on another container). - let alreadyMounted = Set( - vm.mounts - .filter { $0.key != id } - .values.flatMap { $0 } - .filter { $0.type == "virtiofs" } - .map { $0.source } - ) + // prior hotplug (i.e. present on another container or + // a machine volume). + let alreadyMounted: Set = { + var mounted = Set( + vm.storage.containers + .filter { $0.key != id } + .values.flatMap { $0.all } + .filter { $0.type == "virtiofs" } + .map { $0.source }) + mounted.formUnion( + vm.storage.volumes.values + .filter { $0.type == "virtiofs" } + .map { $0.source }) + return mounted + }() var seen: Set = [] for tag in newVirtiofsTags where !alreadyMounted.contains(tag) && seen.insert(tag).inserted { @@ -493,7 +497,7 @@ extension LinuxPod { } if fileMountContext.hasFileMounts { - let containerMounts = vm.mounts[id] ?? [] + let containerMounts = vm.storage.containers[id]?.mounts ?? [] try await updatedFileMountContext.mountHoldingDirectories( vmMounts: containerMounts, agent: agent @@ -557,20 +561,23 @@ extension LinuxPod { try await self.state.withLock { state in try state.phase.validateForCreate() - // Build mountsByID for all containers. + // Build the machine's storage from its containers. // Strip "ro" from rootfs options - we handle readonly via the OCI spec's // root.readonly field and remount in vmexec after setup is complete. // Use transformedMounts from fileMountContext (file mounts become directory shares). - var mountsByID: [String: [Mount]] = [:] + var machineStorage = MachineMounts() for (id, container) in state.containers { var modifiedRootfs = container.rootfs modifiedRootfs.options.removeAll(where: { $0 == "ro" }) - // Filter out shared mounts — those are handled separately as pod volume bind mounts. + // Shared mounts are handled separately as pod volume bind mounts. let containerMounts = container.fileMountContext.transformedMounts.filter { if case .shared = $0.runtimeOptions { return false } return true } - mountsByID[id] = [modifiedRootfs] + containerMounts + machineStorage.containers[id] = ContainerMounts( + rootfs: modifiedRootfs, + mounts: containerMounts + ) } // Validate pod volume names are unique. @@ -597,9 +604,8 @@ extension LinuxPod { } } } - let podVolumeMounts = self.config.volumes.map { $0.toMount() } - if !podVolumeMounts.isEmpty { - mountsByID[self.id] = podVolumeMounts + for volume in self.config.volumes { + machineStorage.volumes[volume.name] = volume.toMount() } // Capture into an immutable `let` so the value is safely usable @@ -608,18 +614,16 @@ extension LinuxPod { // only attaches a virtiofs device when shares are configured, // so mounting an unbacked /run/virtiofs would fail with EINVAL // on the CH backend. - let hasVirtiofsMount = mountsByID.values.contains { mounts in - mounts.contains { mount in - if case .virtiofs = mount.runtimeOptions { return true } - return false - } + let hasVirtiofsMount = machineStorage.ordered.contains { mount in + if case .virtiofs = mount.runtimeOptions { return true } + return false } var vmConfig = VMConfiguration( cpus: self.config.cpus, memoryInBytes: self.config.memoryInBytes, interfaces: self.config.interfaces, - mountsByID: mountsByID, + storage: machineStorage, bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -649,19 +653,17 @@ extension LinuxPod { // /run/virtiofs/. See LinuxContainer for the // VZ vs. CH model split. var seenTags: Set = [] - for (_, attached) in vm.mounts { - for entry in attached where entry.type == "virtiofs" { - guard seenTags.insert(entry.source).inserted else { continue } - let dest = "/run/virtiofs/\(entry.source)" - try await agent.mkdir(path: dest, all: true, perms: 0o755) - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: entry.source, - destination: dest, - options: [] - )) - } + for entry in vm.storage.ordered where entry.type == "virtiofs" { + guard seenTags.insert(entry.source).inserted else { continue } + let dest = "/run/virtiofs/\(entry.source)" + try await agent.mkdir(path: dest, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: entry.source, + destination: dest, + options: [] + )) } } else { try await agent.mount( @@ -728,10 +730,10 @@ extension LinuxPod { // Mount all container rootfs for (_, container) in containers { - guard let attachments = vm.mounts[container.id], let rootfsAttachment = attachments.first else { + guard let attached = vm.storage.containers[container.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found for container \(container.id)") } - var rootfs = rootfsAttachment.to + var rootfs = attached.rootfs.to rootfs.destination = Self.guestRootfsPath(container.id) try await agent.mount(rootfs) } @@ -740,7 +742,7 @@ extension LinuxPod { for (id, container) in containers { if container.fileMountContext.hasFileMounts { var ctx = container.fileMountContext - let containerMounts = vm.mounts[id] ?? [] + let containerMounts = vm.storage.containers[id]?.mounts ?? [] try await ctx.mountHoldingDirectories( vmMounts: containerMounts, agent: agent @@ -750,15 +752,13 @@ extension LinuxPod { } // Mount pod-level volumes. - let podVolumeAttachments = vm.mounts[self.id] ?? [] - for (index, volume) in self.config.volumes.enumerated() { - guard index < podVolumeAttachments.count else { + for volume in self.config.volumes { + guard let attachment = vm.storage.volumes[volume.name] else { throw ContainerizationError( .notFound, message: "attached filesystem not found for pod volume \"\(volume.name)\"" ) } - let attachment = podVolumeAttachments[index] let guestPath = Self.guestVolumePath(volume.name) try await agent.mount( ContainerizationOCI.Mount( @@ -864,10 +864,10 @@ extension LinuxPod { // We don't need the rootfs, nor do OCI runtimes want it included. // Also filter out file mount holding directories - we mount those separately under /run. // Transform virtiofs mounts to bind mounts from /run/virtiofs/{tag} - let containerMounts = createdState.vm.mounts[containerID] ?? [] + let containerMounts = createdState.vm.storage.containers[containerID]?.mounts ?? [] let holdingTags = container.fileMountContext.holdingDirectoryTags var mounts: [ContainerizationOCI.Mount] = - containerMounts.dropFirst() + containerMounts .filter { !holdingTags.contains($0.source) } .map { attached -> ContainerizationOCI.Mount in if attached.type == "virtiofs" { diff --git a/Sources/Containerization/VMConfiguration.swift b/Sources/Containerization/VMConfiguration.swift index 30faebc4d..7a5e8e2c5 100644 --- a/Sources/Containerization/VMConfiguration.swift +++ b/Sources/Containerization/VMConfiguration.swift @@ -72,9 +72,9 @@ public struct VMConfiguration: Sendable { 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]] + /// The storage the machine carries: each container's mounts by role, + /// and the volumes its containers share. + public var storage: MachineMounts /// Optional destination for serial boot logs. public var bootLog: BootLog? /// Enable nested virtualization support. If the VirtualMachineManager @@ -89,14 +89,14 @@ public struct VMConfiguration: Sendable { cpus: Int = 4, memoryInBytes: UInt64 = 1024 * 1024 * 1024, interfaces: [any Interface] = [], - mountsByID: [String: [Mount]] = [:], + storage: MachineMounts = MachineMounts(), bootLog: BootLog? = nil, nestedVirtualization: Bool = false ) { self.cpus = cpus self.memoryInBytes = memoryInBytes self.interfaces = interfaces - self.mountsByID = mountsByID + self.storage = storage self.bootLog = bootLog self.nestedVirtualization = nestedVirtualization } diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 160c50267..670ab5122 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -28,10 +28,10 @@ import Synchronization 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 machine's attached storage. + private let _storage: Mutex + public var storage: MachineAttachments { + _storage.withLock { $0 } } /// The underlying Virtualization framework virtual machine. @@ -40,9 +40,9 @@ public final class VZVirtualMachineInstance: Sendable { /// The dispatch queue used for VZ operations. public var vmQueue: DispatchQueue { queue } - /// Mutate the mount registry. - public func withMountRegistry(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T { - try _mounts.withLock(body) + /// Mutate the storage registry. + public func withStorage(_ body: (inout sending MachineAttachments) throws -> sending T) rethrows -> T { + try _storage.withLock(body) } /// Serialize VM operations with the instance lock. @@ -73,8 +73,9 @@ public final class VZVirtualMachineInstance: Sendable { public var rosetta: Bool /// Toggle nested virtualization support. public var nestedVirtualization: Bool - /// Mount attachments organized by metadata ID. - public var mountsByID: [String: [Mount]] + /// The machine's storage: each container's mounts by role, and the + /// volumes its containers share. + public var storage: MachineMounts /// Network interface attachments. public var interfaces: [any Interface] /// Kernel image. @@ -91,7 +92,7 @@ public final class VZVirtualMachineInstance: Sendable { self.memoryInBytes = 1024.mib() self.rosetta = false self.nestedVirtualization = false - self.mountsByID = [:] + self.storage = MachineMounts() self.interfaces = [] } } @@ -132,7 +133,7 @@ public final class VZVirtualMachineInstance: Sendable { let allocator = Character.blockDeviceTagAllocator() let (mountAttachments, _) = try config.mountAttachments(allocator: allocator) - self._mounts = Mutex(mountAttachments) + self._storage = Mutex(mountAttachments) self.vm = VZVirtualMachine( configuration: try config.toVZ(allocator: allocator), @@ -153,7 +154,7 @@ public protocol VZInstanceExtension: Sendable { _ config: inout VZVirtualMachineConfiguration, allocator: any AddressAllocator, storageDeviceCount: Int, - mountsByID: [String: [Mount]] + storage: MachineMounts ) throws /// Called after the VZVirtualMachine is created but before start. @@ -168,7 +169,7 @@ extension VZInstanceExtension { _ config: inout VZVirtualMachineConfiguration, allocator: any AddressAllocator, storageDeviceCount: Int, - mountsByID: [String: [Mount]] + storage: MachineMounts ) throws {} public func didCreate(_ instance: VZVirtualMachineInstance) throws {} @@ -477,35 +478,33 @@ extension VZVirtualMachineInstance.Configuration { // Track used virtiofs tags to avoid creating duplicate VZ devices. // The same source directory mounted to multiple destinations shares one device. + // The walk is the machine's device order, matching the addresses + // `mountAttachments` hands out walking the same way. var usedVirtioFSTags: Set = [] - 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) + for mount in self.storage.ordered { + if case .virtiofs = mount.runtimeOptions { + let tag = try hashFilePath(path: mount.source) + if usedVirtioFSTags.contains(tag) { + continue } - try mount.configure(config: &config) + 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") - ) + for mount in self.storage.ordered { + 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") @@ -527,7 +526,7 @@ extension VZVirtualMachineInstance.Configuration { 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 ext.configureVZ(&config, allocator: allocator, storageDeviceCount: storageDeviceCount, storage: self.storage) } try config.validate() @@ -535,7 +534,7 @@ extension VZVirtualMachineInstance.Configuration { } func mountAttachments(allocator: any AddressAllocator) throws -> ( - attachments: [String: [AttachedFilesystem]], storageDeviceCount: Int + attachments: MachineAttachments, storageDeviceCount: Int ) { var storageDeviceCount = 0 @@ -548,21 +547,28 @@ extension VZVirtualMachineInstance.Configuration { } } - 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 - } + // The machine's device order: addresses are handed out in the same + // walk `makeConfiguration` creates the devices in. + func attach(_ mount: Mount) throws -> AttachedFilesystem { + let attached = try AttachedFilesystem(mount: mount, allocator: allocator) + if mount.isBlock { + storageDeviceCount += 1 } - attachmentsByID[id] = attachments + return attached + } + + var containers: [String: ContainerAttachments] = [:] + for id in self.storage.containers.keys.sorted() { + guard let container = self.storage.containers[id] else { continue } + containers[id] = try container.map(attach) + } + var volumes: [String: AttachedFilesystem] = [:] + for name in self.storage.volumes.keys.sorted() { + guard let mount = self.storage.volumes[name] else { continue } + volumes[name] = try attach(mount) } - return (attachmentsByID, storageDeviceCount) + return (MachineAttachments(containers: containers, volumes: volumes), storageDeviceCount) } } diff --git a/Sources/Containerization/VZVirtualMachineManager.swift b/Sources/Containerization/VZVirtualMachineManager.swift index 4959bee42..d08884479 100644 --- a/Sources/Containerization/VZVirtualMachineManager.swift +++ b/Sources/Containerization/VZVirtualMachineManager.swift @@ -76,7 +76,7 @@ public struct VZVirtualMachineManager: VirtualMachineManager { instanceConfig.rosetta = self.rosetta instanceConfig.nestedVirtualization = useNestedVirtualization - instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.storage = vmConfig.storage instanceConfig.extensions = vmConfig.extensions }) } diff --git a/Sources/Containerization/VirtualMachineInstance.swift b/Sources/Containerization/VirtualMachineInstance.swift index 302e97ae7..b9340612c 100644 --- a/Sources/Containerization/VirtualMachineInstance.swift +++ b/Sources/Containerization/VirtualMachineInstance.swift @@ -44,7 +44,8 @@ public protocol VirtualMachineInstance: Sendable { // The state of the virtual machine. var state: VirtualMachineInstanceState { get } - var mounts: [String: [AttachedFilesystem]] { get } + /// The machine's attached storage. + var storage: MachineAttachments { get } /// How this VMM exposes virtiofs devices to the guest. Defaults to /// `.unified` (the VZ-shaped behavior); CH overrides to `.perTag`. @@ -73,7 +74,7 @@ public protocol VirtualMachineInstance: Sendable { 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 + /// This is used to add the rootfs and additional mounts to the machine's storage /// so they can be found when building the container's OCI spec. /// - Parameter id: The container ID /// - Parameter rootfs: The rootfs attachment from hotplug diff --git a/Sources/ContainerizationOS/Linux/Swap.swift b/Sources/ContainerizationOS/Linux/Swap.swift new file mode 100644 index 000000000..82006738a --- /dev/null +++ b/Sources/ContainerizationOS/Linux/Swap.swift @@ -0,0 +1,176 @@ +//===----------------------------------------------------------------------===// +// 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 CShim +import ContainerizationError + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// `Swap` is a utility type that contains static helpers for creating a swap +/// file and enabling it as swap space for the guest. +/// +/// The swap area header layout is the kernel's on-disk format, version 1: +/// a page of header whose last 10 bytes are the magic `SWAPSPACE2`, with the +/// version, the last usable page and the page count at fixed offsets from the +/// start of the second kilobyte. +/// https://docs.kernel.org/admin-guide/mm/concepts.html +/// https://github.com/torvalds/linux/blob/master/include/linux/swap.h +public struct Swap: Sendable { + /// Offset of the version field within the swap header, and of the last + /// usable page field that follows it. + private static let versionOffset = 1024 + private static let lastPageOffset = 1028 + /// The magic that marks the last bytes of the first page of a swap area. + private static let magic = "SWAPSPACE2" + /// The queue attributes the kernel exposes for each block device. + public static let blockPath = "/sys/block" + + /// Mount type that marks a block device as a container's swap area, which + /// the agent enables rather than mounts. It travels in the `type` field of + /// the mount the host sends, so both sides read it from here. + /// + /// Kata gives its guest swap the same shape, the host attaching a raw file + /// as a block device and the agent calling `swapon` on it, and carries the + /// request on an RPC of its own (`AddSwap`). Here it travels as a mount so + /// the device path the VMM allocates reaches the guest the way every other + /// attached device's does. + /// https://github.com/kata-containers/kata-containers/blob/main/src/agent/src/rpc.rs + public static let mountType = "swap" + + #if os(Linux) + /// Format the swap area at `path` and enable it. + /// + /// `path` may be a block device or a file that already has its final size, + /// which is what `size` describes; `create` makes such a file. + public static func enable(path: String, size: UInt64, pageSize: Int = 4096) throws { + try format(path: path, size: size, pageSize: pageSize) + try on(path: path) + } + + /// Write the swap area header the kernel expects to an existing block + /// device or fully allocated file. + /// + /// This is what `mkswap` writes, and kata has its host run `mkswap` before + /// attaching the device. That is not open to a host which is not Linux, so + /// the header is written here instead, from the guest that is about to + /// enable it. + /// https://github.com/kata-containers/kata-containers/blob/main/src/runtime-rs/crates/resource/src/cpu_mem/swap.rs + public static func format(path: String, size: UInt64, pageSize: Int = 4096) throws { + let pages = size / UInt64(pageSize) + guard pages > 1 else { + throw ContainerizationError( + .invalidArgument, + message: "swap size \(size) is smaller than the two pages a swap area needs" + ) + } + // The header carries the last page number in 32 bits. + guard let lastPage = UInt32(exactly: pages - 1) else { + throw ContainerizationError( + .invalidArgument, + message: "swap size \(size) exceeds the \(UInt64(UInt32.max) + 1) pages a swap header can carry" + ) + } + + let fd = open(path, O_WRONLY) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + defer { close(fd) } + + var header = [UInt8](repeating: 0, count: pageSize) + header.replaceSubrange(versionOffset..<(versionOffset + 4), with: littleEndianBytes(1)) + header.replaceSubrange( + lastPageOffset..<(lastPageOffset + 4), + with: littleEndianBytes(lastPage) + ) + // The magic occupies the last bytes of the header page. + header.replaceSubrange((pageSize - magic.count).. UInt64 { + let fd = open(path, O_RDONLY) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + defer { close(fd) } + let end = lseek(fd, 0, SEEK_END) + guard end > 0 else { + throw POSIXError.fromErrno() + } + return UInt64(end) + } + + /// Enable the swap area at `path`, asking the kernel to discard the blocks + /// it stops using so the file backing the area does not keep them. + /// + /// Ours: kata enables its swap with no flags, which suits an area on a + /// host disk that was sized once and stays that size. + /// https://github.com/kata-containers/kata-containers/blob/main/src/agent/src/rpc.rs + public static func on(path: String) throws { + try markSolidState(devicePath: path) + guard CZ_swapon(path, CZ_SWAP_DISCARD | CZ_SWAP_DISCARD_PAGES) == 0 else { + throw POSIXError.fromErrno() + } + } + + /// Mark the block device backing the swap area as non rotational. + /// + /// Ours: no other runtime does this, because no other runtime needs the + /// area to give its blocks back. Kata calls `swapon` with no flags at all. + /// + /// The kernel only tracks a swap area in clusters when its device is non + /// rotational, and freeing a cluster is the only thing that schedules a + /// discard. A virtio block device reports as rotational, so without this + /// the area is scanned rather than clustered and the discard flags above + /// never take effect, leaving the file that backs the area holding every + /// block the guest has ever swapped to. + /// https://github.com/torvalds/linux/blob/master/mm/swapfile.c + static func markSolidState(devicePath: String) throws { + let device = URL(fileURLWithPath: devicePath).lastPathComponent + try "0".write( + to: URL(fileURLWithPath: Self.blockPath) + .appendingPathComponent(device) + .appendingPathComponent("queue") + .appendingPathComponent("rotational"), + atomically: false, + encoding: .ascii + ) + } + + private static func littleEndianBytes(_ value: UInt32) -> [UInt8] { + withUnsafeBytes(of: value.littleEndian) { Array($0) } + } + + #endif // os(Linux) +} diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index dd2a91656..1b8d1dd2f 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -137,6 +137,269 @@ extension IntegrationSuite { } } + func testContainerSwap() async throws { + let id = "test-container-swap" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 64.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/cat", "/proc/swaps"] + config.process.stdout = buffer + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "process status \(status) != 0") + } + + // The guest lists the area it enabled, so the device is present and + // the kernel accepted the header the agent wrote to it. + let swaps = String(data: buffer.data, encoding: .utf8) ?? "" + guard swaps.contains("partition") else { + throw IntegrationError.assert(msg: "swap not enabled in guest: '\(swaps)'") + } + } catch { + try? await container.stop() + throw error + } + } + + /// The point of swap: a workload whose pages exceed the memory limit + /// reclaims instead of meeting the out of memory killer. The tmpfs is + /// sized explicitly because its default is half of RAM, which cannot + /// exceed the limit and so would never drive a page out. + func testContainerSwapUnderPressure() async throws { + let id = "test-container-swap-pressure" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 512.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // A shell variable is anonymous memory, which is what the kernel + // reclaims to a swap area, and asking the container's cgroup to + // reclaim puts it there rather than leaving it to the pressure the + // allocation happens to produce. Reading the value back afterwards + // is what shows it made the round trip intact, so the length is + // checked after the area has been measured. + config.process.arguments = [ + "/bin/sh", "-c", + "fill=$(head -c 340000000 /dev/zero | tr '\\0' 'a'); " + + "echo 340M > /sys/fs/cgroup/memory.reclaim; " + + "awk '/SwapTotal|SwapFree/ { print $1, $2 }' /proc/meminfo; " + + "test ${#fill} -eq 340000000", + ] + config.process.stdout = buffer + config.memoryInBytes = 256.mib() + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + // Survival is the first half of the claim: without swap this + // workload is killed rather than reclaimed. + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not survive memory pressure: \(status)") + } + + // And the second half: pages actually reached the swap device. + let out = String(data: buffer.data, encoding: .utf8) ?? "" + let values = out.split(separator: "\n").reduce(into: [String: Int]()) { acc, line in + let parts = line.split(separator: " ") + if parts.count == 2 { acc[String(parts[0])] = Int(parts[1]) } + } + guard let total = values["SwapTotal:"], let free = values["SwapFree:"], total > 0 else { + throw IntegrationError.assert(msg: "guest reported no swap: '\(out)'") + } + guard UInt64(total - free) * 1024 > 64.mib() else { + throw IntegrationError.assert(msg: "little or nothing was swapped out: '\(out)'") + } + } catch { + try? await container.stop() + throw error + } + } + + func testContainerSwapReclaimsFreedBlocks() async throws { + let id = "test-container-swap-reclaim" + let bs = try await bootstrap(id) + + let swapPath = Self.binPath(name: "\(id)-swap.raw") + let swap = try Self.makeSwapDevice(at: swapPath, size: 512.mib()) + defer { try? FileManager.default.removeItem(at: swapPath) } + + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // The allocation is held by a child that exits, because the swap + // slots are only freed once the pages themselves are, and a shell + // that drops a variable keeps the pages in its own allocator. + config.process.arguments = [ + "/bin/sh", "-c", + "sh -c 'fill=$(head -c 200000000 /dev/zero | tr \"\\0\" a); " + + "echo 200M > /sys/fs/cgroup/memory.reclaim; " + + "test ${#fill} -eq 200000000' && sleep 30", + ] + config.memoryInBytes = 256.mib() + config.swapLayer = swap + config.bootLog = bs.bootLog + } + + // The blocks the file holds, rather than the size it reports, because a + // sparse file only ever reports the whole area. URL resource values + // cache after their first read, which a sampler cannot use. + func allocatedBytes() -> UInt64 { + var info = stat() + guard stat(swapPath.absolutePath(), &info) == 0 else { + return 0 + } + return UInt64(info.st_blocks) * 512 + } + + do { + try await container.create() + try await container.start() + + // Watching the file while the workload runs is what separates an + // area that released its blocks from one that never held any. + let peak = Task { + var high: UInt64 = 0 + while !Task.isCancelled { + high = max(high, allocatedBytes()) + try? await Task.sleep(nanoseconds: 200_000_000) + } + return high + } + + let status = try await container.wait() + peak.cancel() + let highWater = await peak.value + try await container.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not complete: \(status)") + } + guard highWater > 100.mib() else { + throw IntegrationError.assert( + msg: "workload never filled the swap area, it held \(highWater) bytes") + } + + // Having been filled, the area releases what the guest stopped + // using, so the file backing it does not hold its high water mark. + let settled = allocatedBytes() + guard settled < highWater / 4 else { + throw IntegrationError.assert( + msg: "swap area kept \(settled) of \(highWater) bytes after the guest freed it") + } + } catch { + try? await container.stop() + throw error + } + } + + #if os(macOS) + /// A swap area does not have to be a file the host sets aside. Pointed at + /// an export whose blocks are held in memory, what the guest swaps out + /// lands in a host process instead, where it is pageable and reaches the + /// host's own swap rather than a store of its own. + func testContainerSwapOnMemoryBackedNBD() async throws { + let id = "test-container-swap-memory-nbd" + let bs = try await bootstrap(id) + + let store = NBDMemoryStore(size: 512.mib()) + let socketPath = "/tmp/nbd-swap-\(UUID().uuidString.prefix(8)).sock" + let server = try NBDServer(store: store, socketPath: socketPath) + defer { server.stop() } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // The area is reached over the export's URL rather than a path, so + // the same mount that names a file names this instead. + config.swapLayer = .block( + format: Swap.mountType, + source: server.url, + destination: "", + options: [] + ) + config.process.arguments = [ + "/bin/sh", "-c", + "awk '/SwapTotal/ { print $2 }' /proc/meminfo; " + + "sh -c 'fill=$(head -c 200000000 /dev/zero | tr \"\\0\" a); " + + "echo 200M > /sys/fs/cgroup/memory.reclaim; " + + "test ${#fill} -eq 200000000'", + ] + config.process.stdout = buffer + config.memoryInBytes = 256.mib() + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + let status = try await container.wait() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "workload did not complete: \(status)") + } + + let out = String(data: buffer.data, encoding: .utf8) ?? "" + guard let total = UInt64(out.trimmingCharacters(in: .whitespacesAndNewlines)), total > 0 else { + throw IntegrationError.assert(msg: "guest enabled no swap area: '\(out)'") + } + // What the guest pushed out was held by the server, which is the + // whole point of keeping the blocks in memory rather than a file. + // The high water mark is what to ask for: a guest gives its swap + // back as it goes away, so what the export holds by now says + // nothing about what passed through it. Enabling an area writes a + // header to it, so the bar is set well above one, or a guest that + // swapped nothing would clear it. + let held = store.peakAllocatedBytes + guard held > 16.mib() else { + throw IntegrationError.assert( + msg: "guest reported \(total) kB of swap but the export never held" + + " more than \(held) bytes") + } + // What grew must come back: the filler exited while the guest was + // still up, freeing its swap slots, and a freed cluster is + // discarded. The discards trail the exit, so the export is watched + // while the machine is still up rather than sampled once, until it + // holds little more than the swap header. + var residual = store.allocatedBytes + let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + while residual > 32.mib(), ContinuousClock.now < deadline { + try await Task.sleep(nanoseconds: 200_000_000) + residual = store.allocatedBytes + } + try await container.stop() + guard residual <= 32.mib() else { + throw IntegrationError.assert( + msg: "export still holds \(residual) bytes after the workload" + + " freed its swap (peak \(held))") + } + } catch { + try? await container.stop() + throw error + } + } + #endif + func testProcessEchoHi() async throws { let id = "test-process-echo-hi" let bs = try await bootstrap(id) diff --git a/Sources/Integration/NBDBackingStore.swift b/Sources/Integration/NBDBackingStore.swift new file mode 100644 index 000000000..5f12edaa5 --- /dev/null +++ b/Sources/Integration/NBDBackingStore.swift @@ -0,0 +1,343 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Synchronization + +#if os(macOS) + +/// Where an NBD export keeps the blocks it serves. +/// +/// A server holds one of these and every connection to that export shares it, +/// so what one connection writes another reads back. +protocol NBDBackingStore: Sendable { + /// The size of the export, which the server reports during the handshake. + var size: UInt64 { get } + + /// Whether the export only ever reads. A server says so during the + /// handshake and turns away everything that would write. + var isReadOnly: Bool { get } + + /// Read `length` bytes from `offset`. Returns nil when the read fails. + func read(offset: UInt64, length: Int) -> [UInt8]? + + /// Write `data` at `offset`. Returns false when the write fails. + func write(offset: UInt64, data: [UInt8]) -> Bool + + /// Let go of `length` bytes at `offset`, which the client has said it no + /// longer needs. Returns false only when the range is outside the export. + /// A store may release less than was asked, or nothing; what a released + /// range reads back as is the store's own affair. + /// + /// The protocol allows a server to do nothing here, but a swap area is + /// rewritten constantly and never shrinks on its own, so a store that + /// ignores this grows until the export is closed. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + func discard(offset: UInt64, length: Int) -> Bool + + /// Put anything held back where it belongs before replying to a flush. + func flush() + + /// Release whatever the store holds. + func close() +} + +/// A run of the export that shares one allocation state, which is what a client +/// asking about block status is told. +struct NBDExtent: Sendable { + /// The extent is not allocated in the store behind the export. + static let stateHole: UInt32 = 0x1 + /// The extent reads back as zeroes. + static let stateZero: UInt32 = 0x2 + + var length: UInt32 + var flags: UInt32 +} + +extension NBDBackingStore { + /// Most stores are written to, so saying nothing means so. + var isReadOnly: Bool { false } + + /// How the export is laid out over `length` bytes from `offset`. + /// + /// A store that cannot tell says the whole range is allocated, which is + /// true of any store and costs a client only the chance to skip a hole. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + [NBDExtent(length: UInt32(length), flags: 0)] + } + + /// Whether a request of `length` bytes at `offset` lies inside the export. + /// The protocol asks a server to turn away one that does not rather than + /// let it reach the store. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + func covers(offset: UInt64, length: Int) -> Bool { + guard length >= 0 else { + return false + } + let (end, overflowed) = offset.addingReportingOverflow(UInt64(length)) + return !overflowed && end <= self.size + } +} + +/// A store that keeps its blocks in a file. +final class NBDFileStore: NBDBackingStore { + private let fd: Mutex + let size: UInt64 + + init?(path: String) { + let descriptor = open(path, O_RDWR) + guard descriptor >= 0 else { + return nil + } + var st = stat() + guard fstat(descriptor, &st) == 0 else { + _ = Foundation.close(descriptor) + return nil + } + self.fd = Mutex(descriptor) + self.size = UInt64(st.st_size) + } + + func read(offset: UInt64, length: Int) -> [UInt8]? { + var buffer = [UInt8](repeating: 0, count: length) + let read = self.fd.withLock { descriptor in + buffer.withUnsafeMutableBytes { + pread(descriptor, $0.baseAddress, length, off_t(offset)) + } + } + guard read == length else { + return nil + } + return buffer + } + + func write(offset: UInt64, data: [UInt8]) -> Bool { + self.fd.withLock { descriptor in + data.withUnsafeBytes { + pwrite(descriptor, $0.baseAddress, data.count, off_t(offset)) == data.count + } + } + } + + func discard(offset: UInt64, length: Int) -> Bool { + guard offset + UInt64(length) <= self.size else { + return false + } + // Punching a hole is how a file gives blocks back without changing the + // length the export reports. The punch is best effort: a range the + // filesystem will not punch, such as one it cannot align, keeps its + // blocks, which the protocol permits. + self.fd.withLock { descriptor in + var punch = fpunchhole_t( + fp_flags: 0, reserved: 0, fp_offset: off_t(offset), fp_length: off_t(length)) + _ = fcntl(descriptor, F_PUNCHHOLE, &punch) + } + return true + } + + /// A file says where its holes are through the same seeks a sparse copy + /// uses, so a client is told what the filesystem already knows. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + self.fd.withLock { descriptor in + var runs: [NBDExtent] = [] + var at = off_t(offset) + let end = off_t(offset) + off_t(length) + while at < end { + let nextData = lseek(descriptor, at, SEEK_DATA) + if nextData < 0, errno != ENXIO { + // The seek itself failed, so nothing is known about the + // layout; say the range is allocated, which is true of any + // range and costs a client only the chance to skip a hole. + return [NBDExtent(length: UInt32(length), flags: 0)] + } + if nextData < 0 || nextData >= end { + // Nothing written between here and the end of the range. + runs.append( + NBDExtent( + length: UInt32(end - at), + flags: NBDExtent.stateHole | NBDExtent.stateZero)) + break + } + if nextData > at { + runs.append( + NBDExtent( + length: UInt32(nextData - at), + flags: NBDExtent.stateHole | NBDExtent.stateZero)) + } + let nextHole = lseek(descriptor, nextData, SEEK_HOLE) + let dataEnd = (nextHole < 0 || nextHole > end) ? end : nextHole + runs.append(NBDExtent(length: UInt32(dataEnd - nextData), flags: 0)) + at = dataEnd + } + return runs.isEmpty ? [NBDExtent(length: UInt32(length), flags: 0)] : runs + } + } + + func flush() { + self.fd.withLock { _ = fsync($0) } + } + + func close() { + self.fd.withLock { descriptor in + if descriptor >= 0 { + _ = Foundation.close(descriptor) + } + } + } +} + +/// A store that keeps its blocks in the memory of the process serving them. +/// +/// The point of holding them here rather than in a file is where they end up +/// under pressure. Memory a host process holds is pageable, so the host decides +/// when these blocks go to its own swap, and they share the one pool the rest +/// of the system draws on. Blocks in a file take space of their own instead. +/// +/// Only the chunks actually written are held, so an export costs nothing until +/// something is stored in it, the way a sparse file costs nothing until it is +/// written to. +final class NBDMemoryStore: NBDBackingStore { + /// A chunk is a page, because that is the unit a guest swaps in and out. + /// Anything larger rounds every scattered page write up to its size, which + /// costs both the memory the rounding wastes and the copying of the part + /// that was not written. + static let chunkSize = 4096 + + private let chunks: Mutex<[UInt64: [UInt8]]> = Mutex([:]) + let size: UInt64 + + init(size: UInt64) { + self.size = size + } + + /// The bytes actually held, which is what the export costs the host. + var allocatedBytes: Int { + self.chunks.withLock { $0.count * Self.chunkSize } + } + + /// The most the export has ever held. + /// + /// What it holds right now says nothing about what passed through it, since + /// a client that discards what it has finished with leaves an export as + /// empty as it started. This is what a reader wanting to know whether + /// anything was ever stored should look at. + var peakAllocatedBytes: Int { + self.peakChunks.withLock { $0 * Self.chunkSize } + } + + private let peakChunks: Mutex = Mutex(0) + + func read(offset: UInt64, length: Int) -> [UInt8]? { + guard offset + UInt64(length) <= self.size else { + return nil + } + var out = [UInt8](repeating: 0, count: length) + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, inChunk, inSpan, span in + // A chunk never written reads back as the zeroes it started as. + guard let chunk = chunks[index] else { + return + } + out.replaceSubrange(inSpan..<(inSpan + span), with: chunk[inChunk..<(inChunk + span)]) + } + } + return out + } + + func write(offset: UInt64, data: [UInt8]) -> Bool { + guard offset + UInt64(data.count) <= self.size else { + return false + } + let held = self.chunks.withLock { chunks -> Int in + self.forEachSpan(offset: offset, length: data.count) { index, inChunk, inSpan, span in + var chunk = chunks[index] ?? [UInt8](repeating: 0, count: Self.chunkSize) + chunk.replaceSubrange(inChunk..<(inChunk + span), with: data[inSpan..<(inSpan + span)]) + chunks[index] = chunk + } + return chunks.count + } + self.peakChunks.withLock { $0 = max($0, held) } + return true + } + + func discard(offset: UInt64, length: Int) -> Bool { + guard offset + UInt64(length) <= self.size else { + return false + } + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, inChunk, _, span in + // Only a whole chunk can go; a chunk the client still wants part + // of keeps its place, with the discarded part zeroed. + if inChunk == 0 && span == Self.chunkSize { + chunks.removeValue(forKey: index) + } else if var chunk = chunks[index] { + chunk.replaceSubrange( + inChunk..<(inChunk + span), with: [UInt8](repeating: 0, count: span)) + chunks[index] = chunk + } + } + } + return true + } + + /// A store in memory knows exactly which chunks it holds, so it can say + /// where the holes are rather than claiming the whole range is written. + func extents(offset: UInt64, length: Int) -> [NBDExtent] { + var runs: [NBDExtent] = [] + self.chunks.withLock { chunks in + self.forEachSpan(offset: offset, length: length) { index, _, _, span in + let flags: UInt32 = + chunks[index] == nil ? (NBDExtent.stateHole | NBDExtent.stateZero) : 0 + if var last = runs.last, last.flags == flags { + last.length += UInt32(span) + runs[runs.count - 1] = last + } else { + runs.append(NBDExtent(length: UInt32(span), flags: flags)) + } + } + } + return runs + } + + /// Nothing is held anywhere else, so a flush has nothing to do. + func flush() {} + + func close() { + self.chunks.withLock { $0.removeAll() } + } + + /// Walk the chunks a request covers, handing each the range it owns. + private func forEachSpan( + offset: UInt64, + length: Int, + _ body: (_ index: UInt64, _ inChunk: Int, _ inSpan: Int, _ span: Int) -> Void + ) { + var remaining = length + var at = offset + var taken = 0 + while remaining > 0 { + let index = at / UInt64(Self.chunkSize) + let inChunk = Int(at % UInt64(Self.chunkSize)) + let span = min(Self.chunkSize - inChunk, remaining) + body(index, inChunk, taken, span) + remaining -= span + taken += span + at += UInt64(span) + } + } +} + +#endif diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index 982c8752f..34d572b64 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -31,23 +31,27 @@ final class NBDServer: Sendable { private let group: EventLoopGroup let url: String - init(filePath: String, socketPath: String, logger: Logger? = nil) throws { + private let store: NBDBackingStore + + init(store: NBDBackingStore, socketPath: String, logger: Logger? = nil) throws { self.socketPath = socketPath + self.store = store self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) try? FileManager.default.removeItem(atPath: socketPath) - self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger) + self.channel = try Self.bootstrap(group: self.group, store: store, logger: logger) .bind(unixDomainSocketPath: socketPath) .wait() self.url = "nbd+unix:///?socket=\(socketPath)" } - init(filePath: String, port: Int, logger: Logger? = nil) throws { + init(store: NBDBackingStore, port: Int, logger: Logger? = nil) throws { self.socketPath = nil + self.store = store self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) - self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger) + self.channel = try Self.bootstrap(group: self.group, store: store, logger: logger) .bind(host: "127.0.0.1", port: port) .wait() @@ -57,21 +61,37 @@ final class NBDServer: Sendable { self.url = "nbd://127.0.0.1:\(boundPort)" } + convenience init(filePath: String, socketPath: String, logger: Logger? = nil) throws { + try self.init(store: Self.fileStore(filePath), socketPath: socketPath, logger: logger) + } + + convenience init(filePath: String, port: Int, logger: Logger? = nil) throws { + try self.init(store: Self.fileStore(filePath), port: port, logger: logger) + } + + private static func fileStore(_ path: String) throws -> NBDBackingStore { + guard let store = NBDFileStore(path: path) else { + throw ContainerizationError(.internalError, message: "NBD server failed to open \(path)") + } + return store + } + func stop() { try? channel.close().wait() try? group.syncShutdownGracefully() + self.store.close() if let socketPath { try? FileManager.default.removeItem(atPath: socketPath) } } - private static func bootstrap(group: EventLoopGroup, filePath: String, logger: Logger?) -> ServerBootstrap { + private static func bootstrap(group: EventLoopGroup, store: NBDBackingStore, logger: Logger?) -> ServerBootstrap { ServerBootstrap(group: group) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( - NBDConnectionHandler(filePath: filePath, logger: logger) + NBDConnectionHandler(store: store, logger: logger) ) } } @@ -91,13 +111,34 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let optExportName: UInt32 = 1 static let optAbort: UInt32 = 2 + static let optList: UInt32 = 3 static let optInfo: UInt32 = 6 static let optGo: UInt32 = 7 + static let optStructuredReply: UInt32 = 8 + static let optListMetaContext: UInt32 = 9 + static let optSetMetaContext: UInt32 = 10 + + /// The one export a server here serves, which has no name of its own. + static let exportName = "" + /// The layout context a client asks about, and the only one answered. + static let metaContextAllocation = "base:allocation" + static let metaContextID: UInt32 = 1 static let cmdRead: UInt16 = 0 static let cmdWrite: UInt16 = 1 static let cmdDisc: UInt16 = 2 static let cmdFlush: UInt16 = 3 + static let cmdTrim: UInt16 = 4 + static let cmdCache: UInt16 = 5 + static let cmdWriteZeroes: UInt16 = 6 + + /// Command flags travel in the two bytes after the request magic. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + static let cmdFlagFUA: UInt16 = 0x1 + static let cmdFlagNoHole: UInt16 = 0x2 + static let cmdFlagDF: UInt16 = 0x4 + static let cmdFlagReqOne: UInt16 = 0x8 + static let cmdBlockStatus: UInt16 = 7 static let flagFixedNewstyle: UInt16 = 0x1 static let flagNoZeroes: UInt16 = 0x2 @@ -106,23 +147,59 @@ private final class NBDConnectionHandler: ChannelInboundHandler { static let transmitHasFlags: UInt16 = 0x1 static let transmitSendFlush: UInt16 = 0x4 static let transmitSendFUA: UInt16 = 0x8 + /// A client is not allowed to send a trim without being told the server + /// takes them, so an export that never sets this is never asked to let + /// anything go, however much the guest has finished with. + /// https://github.com/NetworkBlockDevice/nbd/blob/master/doc/proto.md + static let transmitSendTrim: UInt16 = 0x20 + static let transmitReadOnly: UInt16 = 0x2 + static let transmitSendWriteZeroes: UInt16 = 0x40 + static let transmitSendCache: UInt16 = 0x400 + /// Every connection to an export here serves the one store behind it, so a + /// flush on any of them covers what was written on the others, which is + /// what lets a client spread its work across several. + static let transmitCanMultiConn: UInt16 = 0x100 + static let transmitSendDF: UInt16 = 0x80 static let repACK: UInt32 = 1 + static let repServer: UInt32 = 2 static let repInfo: UInt32 = 3 + static let repMetaContext: UInt32 = 4 static let repErrUnsup: UInt32 = 0x8000_0001 + + /// Structured replies carry their own framing, so that a read can name the + /// offset it answers and a hole can be sent without its zeroes. + static let structuredReplyMagic: UInt32 = 0x668e_33ef + static let replyFlagDone: UInt16 = 0x1 + static let replyTypeNone: UInt16 = 0 + static let replyTypeOffsetData: UInt16 = 1 + static let replyTypeOffsetHole: UInt16 = 2 + static let replyTypeBlockStatus: UInt16 = 5 + static let replyTypeError: UInt16 = 32769 + static let replyTypeErrorOffset: UInt16 = 32770 static let infoExport: UInt16 = 0 static let infoBlockSize: UInt16 = 3 // NBD error codes static let errOK: UInt32 = 0 + static let errPerm: UInt32 = 1 static let errIO: UInt32 = 5 + static let errNoMem: UInt32 = 12 + static let errInval: UInt32 = 22 + static let errNoSpc: UInt32 = 28 + static let errOverflow: UInt32 = 75 static let errNotsup: UInt32 = 95 + static let errShutdown: UInt32 = 108 - private let fileFD: Int32 + private let store: NBDBackingStore private let fileSize: UInt64 private let logger: Logger? private var buffer: ByteBuffer = ByteBuffer() private var state: ConnectionState = .handshake + /// Whether the client asked for replies that carry their own framing. + private var structuredReplies = false + /// Whether the client asked to be told about the export's layout. + private var metaContextSelected = false private enum ConnectionState { case handshake @@ -130,24 +207,14 @@ private final class NBDConnectionHandler: ChannelInboundHandler { case transmission } - init(filePath: String, logger: Logger?) { - self.fileFD = open(filePath, O_RDWR) + init(store: NBDBackingStore, logger: Logger?) { + self.store = store + self.fileSize = store.size self.logger = logger - guard fileFD >= 0 else { - self.fileSize = 0 - logger?.error("NBD server: failed to open \(filePath), errno=\(errno)") - return - } - var st = stat() - if fstat(self.fileFD, &st) == 0 { - self.fileSize = UInt64(st.st_size) - } else { - self.fileSize = 0 - } } func channelActive(context: ChannelHandlerContext) { - guard fileFD >= 0 else { + guard fileSize > 0 else { context.close(promise: nil) return } @@ -160,9 +227,8 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } func channelInactive(context: ChannelHandlerContext) { - if fileFD >= 0 { - close(fileFD) - } + // Every connection to an export serves the one store behind it, so a + // client going away is not what ends it. The server closes it instead. } func channelRead(context: ChannelHandlerContext, data: NIOAny) { @@ -213,7 +279,13 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return } - let transmitFlags = Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA + var transmitFlags = + Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA + | Self.transmitSendTrim | Self.transmitSendWriteZeroes | Self.transmitSendCache + | Self.transmitCanMultiConn | Self.transmitSendDF + if store.isReadOnly { + transmitFlags |= Self.transmitReadOnly + } switch optType { case Self.optExportName: @@ -283,6 +355,49 @@ private final class NBDConnectionHandler: ChannelInboundHandler { context.close(promise: nil) return + case Self.optList: + // One export, and it goes by no name. + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + let name = Self.exportName + var listing = context.channel.allocator.buffer(capacity: 32) + writeOptReply( + &listing, optType: optType, replyType: Self.repServer, + dataLen: UInt32(4 + name.utf8.count)) + listing.writeInteger(UInt32(name.utf8.count)) + listing.writeString(name) + writeOptReply(&listing, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(listing), promise: nil) + + case Self.optStructuredReply: + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + structuredReplies = true + var reply = context.channel.allocator.buffer(capacity: 20) + writeOptReply(&reply, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + + case Self.optSetMetaContext, Self.optListMetaContext: + // The queries name what a client wants to ask about later. + // Only the layout of the export is on offer, so a query + // that asks for it, or for everything, is answered and the + // rest are passed over. + let payload = buffer.getSlice(at: buffer.readerIndex, length: Int(dataLen)) + buffer.moveReaderIndex(forwardBy: Int(dataLen)) + let wanted = Self.queriedContexts(payload) + var reply = context.channel.allocator.buffer(capacity: 64) + if wanted { + let name = Self.metaContextAllocation + writeOptReply( + &reply, optType: optType, replyType: Self.repMetaContext, + dataLen: UInt32(4 + name.utf8.count)) + reply.writeInteger(Self.metaContextID) + reply.writeString(name) + if optType == Self.optSetMetaContext { + metaContextSelected = true + } + } + writeOptReply(&reply, optType: optType, replyType: Self.repACK, dataLen: 0) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + default: if dataLen > 0 { buffer.moveReaderIndex(forwardBy: Int(dataLen)) @@ -299,6 +414,7 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } let readerIndex = buffer.readerIndex guard let magic = buffer.getInteger(at: readerIndex, as: UInt32.self), + let cmdFlags = buffer.getInteger(at: readerIndex + 4, as: UInt16.self), let cmdType = buffer.getInteger(at: readerIndex + 6, as: UInt16.self), let cookie = buffer.getInteger(at: readerIndex + 8, as: UInt64.self), let offset = buffer.getInteger(at: readerIndex + 16, as: UInt64.self), @@ -312,6 +428,56 @@ private final class NBDConnectionHandler: ChannelInboundHandler { return } + /// A command that has been dealt with, but whose reply the + /// protocol holds back until what it wrote is durable when the + /// client asked for that. + func replyHonouringFUA(_ error: UInt32) { + if cmdFlags & Self.cmdFlagFUA != 0 && error == Self.errOK { + store.flush() + } + var reply = context.channel.allocator.buffer(capacity: 16) + writeSimpleReply(&reply, cookie: cookie, error: error) + context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + } + + // An export that only reads turns away everything that writes, + // and a request reaching past the end of one is refused rather + // than passed to the store to fail on. + let writes = + cmdType == Self.cmdWrite || cmdType == Self.cmdTrim + || cmdType == Self.cmdWriteZeroes + let addressed = + cmdType == Self.cmdRead || cmdType == Self.cmdWrite || cmdType == Self.cmdTrim + || cmdType == Self.cmdWriteZeroes || cmdType == Self.cmdCache + || cmdType == Self.cmdBlockStatus + if writes && store.isReadOnly { + if cmdType == Self.cmdWrite { + // A refused write is consumed whole, so its payload is + // never read back as the next request's header; the + // refusal waits alongside the acceptance for all of it. + guard buffer.readableBytes >= 28 + Int(length) else { + return + } + buffer.moveReaderIndex(forwardBy: 28 + Int(length)) + } else { + buffer.moveReaderIndex(forwardBy: 28) + } + replyHonouringFUA(Self.errPerm) + continue + } + if addressed && !store.covers(offset: offset, length: Int(length)) { + if cmdType == Self.cmdWrite { + guard buffer.readableBytes >= 28 + Int(length) else { + return + } + buffer.moveReaderIndex(forwardBy: 28 + Int(length)) + } else { + buffer.moveReaderIndex(forwardBy: 28) + } + replyHonouringFUA(Self.errInval) + continue + } + switch cmdType { case Self.cmdWrite: // Need the full write payload before processing. @@ -329,19 +495,61 @@ private final class NBDConnectionHandler: ChannelInboundHandler { } return Int(length) } - let n = pwrite(fileFD, &writeData, Int(length), off_t(offset)) - var reply = context.channel.allocator.buffer(capacity: 16) - writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK) - context.writeAndFlush(wrapOutboundOut(reply), promise: nil) + let stored = store.write(offset: offset, data: writeData) + replyHonouringFUA(stored ? Self.errOK : Self.errIO) case Self.cmdRead: buffer.moveReaderIndex(forwardBy: 28) - var readBuf = [UInt8](repeating: 0, count: Int(length)) - let n = pread(fileFD, &readBuf, Int(length), off_t(offset)) - var reply = context.channel.allocator.buffer(capacity: 16 + Int(length)) - writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK) - if n >= 0 { - reply.writeBytes(readBuf[0.. Bool { + guard var payload else { + return false + } + guard let nameLen = payload.readInteger(as: UInt32.self), + payload.readSlice(length: Int(nameLen)) != nil, + let queryCount = payload.readInteger(as: UInt32.self) + else { + return false + } + if queryCount == 0 { + return true + } + for _ in 0.. Containerization.Mount { let dir = Self.testDir.appending(component: "\(testID)-rootfs-dir") try? FileManager.default.removeItem(at: dir) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9dae84351..8a5e05c1b 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -135,7 +135,19 @@ struct IntegrationSuite: AsyncParsableCommand { FileManager.default.uniqueTemporaryDirectory(create: true) }() + /// The scratch directory of the test running on this task. The runner + /// creates one per test and deletes it when the test finishes, so a + /// test's clones and layers hold disk only while it runs. + @TaskLocal static var currentTestDir: URL? + static var testDir: URL { + currentTestDir ?? _testDir + } + + /// The run-shared directory holding unpacked images, which every test's + /// bootstrap clones from. It lives for the whole run and is removed at + /// the end. + static var imageCacheDir: URL { _testDir } @@ -195,6 +207,27 @@ struct IntegrationSuite: AsyncParsableCommand { static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + /// The swap area a test hands to a container. A suite that builds its own + /// containers owns the file behind them, the way `ContainerManager` owns + /// the ones it makes for callers that do not. + static func makeSwapDevice(at path: URL, size: UInt64) throws -> Containerization.Mount { + guard FileManager.default.createFile(atPath: path.absolutePath(), contents: nil) else { + throw IntegrationError.assert(msg: "failed to create swap device at \(path.absolutePath())") + } + let handle = try FileHandle(forWritingTo: path) + defer { try? handle.close() } + try handle.truncate(atOffset: size) + // A swap area holds nothing that outlives the container, so the host + // has no reason to synchronize it to permanent storage. + return .block( + format: Swap.mountType, + source: path.absolutePath(), + destination: "", + options: [], + runtimeOptions: ["vzDiskImageSynchronizationMode=none"] + ) + } + func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootLog: BootLog) { let reference = "ghcr.io/linuxcontainers/alpine:3.20" let store = Self.imageStore @@ -233,7 +266,7 @@ struct IntegrationSuite: AsyncParsableCommand { let platform = Platform(arch: "arm64", os: "linux", variant: "v8") // Unpack to shared location with coordination to prevent concurrent unpacks - let fsPath = Self.testDir.appending(component: image.digest) + let fsPath = Self.imageCacheDir.appending(component: image.digest) let fs = try await Self.unpackCoordinator.unpack(key: fsPath.absolutePath()) { do { let unpacker = EXT4Unpacker(capacityInBytes: 2.gib()) @@ -251,25 +284,6 @@ struct IntegrationSuite: AsyncParsableCommand { } } - // Reap any per-test artifacts left over from prior tests. With - // `--max-concurrency 1` (linux-integration default) this runs after - // the previous test has fully completed, so it's race-free; on - // macOS where tests can run in parallel we just keep all files — - // disk usage isn't a concern there. Each per-test bootstrap clones - // a ~2GB rootfs and a ~512MB initfs, so without reaping the dev - // container fills its CoW layer in ~10 tests. - if self.maxConcurrency == 1 { - let preserve = fsPath.absolutePath() - if let entries = try? FileManager.default.contentsOfDirectory( - at: Self.testDir, - includingPropertiesForKeys: nil - ) { - for url in entries where url.absolutePath() != preserve { - try? FileManager.default.removeItem(at: url) - } - } - } - // Clone to test-specific path let clPath = Self.testDir.appending(component: "\(testID).ext4").absolutePath() try? FileManager.default.removeItem(atPath: clPath) @@ -621,6 +635,12 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod filesystem operation", testPodFilesystemOperation), Test("pod shared disk image volume", testPodSharedDiskImageVolume), Test("pod shared tmpfs volume", testPodSharedTmpfsVolume), + + // Swap + Test("container swap", testContainerSwap), + Test("container swap under pressure", testContainerSwapUnderPressure), + Test("container swap reclaims freed blocks", testContainerSwapReclaimsFreedBlocks), + Test("container swap on memory backed NBD", testContainerSwapOnMemoryBackedNBD), ] + macOS26Tests() let tests: [Test] = crossPlatformTests + macOSOnlyTests #else @@ -654,11 +674,14 @@ struct IntegrationSuite: AsyncParsableCommand { for _ in 0.. String { + let path = FileManager.default.temporaryDirectory + .appendingPathComponent("swap-\(UUID().uuidString)").path + try #require( + FileManager.default.createFile( + atPath: path, + contents: Data(count: Int(pages) * pageSize) + ) + ) + return path + } + + /// The header the kernel reads back is the one `mkswap` writes: version 1 + /// and the last usable page at the documented offsets, and the magic in the + /// final bytes of the first page. + @Test func formatWritesTheSwapAreaHeader() throws { + let pageSize = 4096 + let pages: UInt64 = 32 + let path = try makeArea(pages: pages, pageSize: pageSize) + defer { try? FileManager.default.removeItem(atPath: path) } + + try Swap.format(path: path, size: pages * UInt64(pageSize), pageSize: pageSize) + + let header = try Data(contentsOf: URL(fileURLWithPath: path))[0..