Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f10eaca
Resolve test directory paths before reaping per-test files
MayCXC Aug 1, 2026
6499f7d
Say how to run the tests that macOS skips
MayCXC Aug 2, 2026
bf4d7dc
Test against the kernel release the shipped tool installs
MayCXC Aug 2, 2026
21f1f16
Run each integration test in its own scratch directory
MayCXC Aug 12, 2026
d244d7a
Give the machine's storage the shape its containers have
MayCXC Aug 12, 2026
b3d7208
Add guest swap support
MayCXC Aug 1, 2026
d471d28
Give a pod a swap area its containers share
MayCXC Aug 2, 2026
c0d0220
Hold a container to the swap limit it was given
MayCXC Aug 2, 2026
2cbb723
Take the swap total as given when memory is unlimited
MayCXC Aug 2, 2026
be4f9cc
Let a pod's containers copy files in and out
MayCXC Aug 7, 2026
8f7a007
Give a pod the machine it asked for
MayCXC Aug 7, 2026
ea2a9a2
Let a pod's containers be given a writable layer
MayCXC Aug 7, 2026
9adf57c
Name the project the guest file transfer belongs to
MayCXC Aug 8, 2026
bf57208
Let a pod give a container's place back
MayCXC Aug 10, 2026
df2bc3e
Let the host reclaim memory from a running container
MayCXC Aug 1, 2026
69565bf
Reclaim guest freed memory on the cloud hypervisor backend
MayCXC Aug 1, 2026
6d676f0
Let the cloud hypervisor backend hold a target memory size
MayCXC Aug 2, 2026
c8fe91c
Compact a guest before taking memory back from it
MayCXC Aug 2, 2026
bb1a5b4
Let a pod gather its guest's memory before taking any back
MayCXC Aug 12, 2026
33c07c7
Work out how much memory to ask a machine to give back
MayCXC Aug 2, 2026
faddab1
Return a machine's freed memory to the host while it runs
MayCXC Aug 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)))
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
36 changes: 36 additions & 0 deletions Sources/CShim/include/swap.h
Original file line number Diff line number Diff line change
@@ -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 <sys/swap.h>, 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
23 changes: 23 additions & 0 deletions Sources/CShim/swap.c
Original file line number Diff line number Diff line change
@@ -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 <sys/swap.h>

#include "swap.h"

int CZ_swapon(const char *path, int flags) { return swapon(path, flags); }
#endif
7 changes: 7 additions & 0 deletions Sources/CloudHypervisor/Endpoints/Client+VM.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ extension CloudHypervisor.Client {
try await put("/api/v1/vm.boot")
}

/// Resize a running VM's cpus, memory or balloon.
///
/// Maps to `PUT /api/v1/vm.resize` in the Cloud Hypervisor REST API.
public func vmResize(_ resize: CloudHypervisor.VmResize) async throws {
try await put("/api/v1/vm.resize", body: resize)
}

/// Shut down the VM.
///
/// Maps to `PUT /api/v1/vm.shutdown` in the Cloud Hypervisor REST API.
Expand Down
63 changes: 63 additions & 0 deletions Sources/CloudHypervisor/Types/VmConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ extension CloudHypervisor {
public var net: [NetConfig]?
public var fs: [FsConfig]?
public var vsock: VsockConfig?
public var balloon: BalloonConfig?
public var console: ConsoleConfig
public var serial: ConsoleConfig

Expand All @@ -39,6 +40,7 @@ extension CloudHypervisor {
net: [NetConfig]? = nil,
fs: [FsConfig]? = nil,
vsock: VsockConfig? = nil,
balloon: BalloonConfig? = nil,
console: ConsoleConfig,
serial: ConsoleConfig
) {
Expand All @@ -49,6 +51,7 @@ extension CloudHypervisor {
self.net = net
self.fs = fs
self.vsock = vsock
self.balloon = balloon
self.console = console
self.serial = serial
}
Expand All @@ -61,6 +64,7 @@ extension CloudHypervisor {
case net
case fs
case vsock
case balloon
case console
case serial
}
Expand Down Expand Up @@ -121,6 +125,65 @@ extension CloudHypervisor {
}
}

// MARK: - VmResize

/// Resize request for a running VM.
///
/// Maps to `VmResize` in the Cloud Hypervisor OpenAPI spec.
public struct VmResize: Sendable, Codable, Equatable {
/// Desired vCPU count.
public var desiredVcpus: Int?
/// Desired memory in bytes.
public var desiredRam: Int64?
/// Desired balloon size in bytes. Growing the balloon takes memory from
/// the guest and gives it to the host.
public var desiredBalloon: Int64?

public init(desiredVcpus: Int? = nil, desiredRam: Int64? = nil, desiredBalloon: Int64? = nil) {
self.desiredVcpus = desiredVcpus
self.desiredRam = desiredRam
self.desiredBalloon = desiredBalloon
}

enum CodingKeys: String, CodingKey {
case desiredVcpus = "desired_vcpus"
case desiredRam = "desired_ram"
case desiredBalloon = "desired_balloon"
}
}

// MARK: - BalloonConfig

/// Memory balloon for handing guest memory back to the host.
///
/// Maps to `BalloonConfig` in the Cloud Hypervisor OpenAPI spec.
public struct BalloonConfig: Sendable, Codable, Equatable {
/// Balloon size in bytes. Zero leaves every page with the guest until
/// something asks for it back.
public var size: UInt64
/// Give pages back to the guest when it runs out of memory.
public var deflateOnOom: Bool?
/// Report the pages the guest frees, so the host can reclaim them
/// without anything having to decide a balloon size.
public var freePageReporting: Bool?

public init(
size: UInt64 = 0,
deflateOnOom: Bool? = nil,
freePageReporting: Bool? = nil
) {
self.size = size
self.deflateOnOom = deflateOnOom
self.freePageReporting = freePageReporting
}

enum CodingKeys: String, CodingKey {
case size
case deflateOnOom = "deflate_on_oom"
case freePageReporting = "free_page_reporting"
}
}

// MARK: - PayloadConfig

/// Kernel / initramfs / cmdline payload for a VM.
Expand Down
81 changes: 40 additions & 41 deletions Sources/Containerization/CHHotplugProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,7 +50,7 @@ final class CHHotplugProvider: HotplugProvider {
private let workDir: URL
private let virtiofsdBinaryOverride: URL?
private let allocator: any AddressAllocator<Character>
private let _mounts: Mutex<[String: [AttachedFilesystem]]>
private let _storage: Mutex<MachineAttachments>
private let _records: Mutex<[String: [HotplugRecord]]>
private let _tags: Mutex<[String: VirtiofsdTagState]>
/// Serializes per-tag virtiofsd spawn so a concurrent hotplug for the
Expand All @@ -65,14 +65,14 @@ final class CHHotplugProvider: HotplugProvider {
workDir: URL,
virtiofsdBinary: URL?,
allocator: any AddressAllocator<Character>,
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()
Expand All @@ -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<T: Sendable>(
_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T
func withStorage<T: Sendable>(
_ body: (inout sending MachineAttachments) throws -> sending T
) rethrows -> T {
try _mounts.withLock(body)
try _storage.withLock(body)
}

// MARK: - HotplugProvider conformance
Expand Down Expand Up @@ -150,13 +150,14 @@ final class CHHotplugProvider: HotplugProvider {
}
}

func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws {
var attached: [AttachedFilesystem] = [rootfs]
func registerMounts(id: String, rootfs: AttachedFilesystem, writableLayer: AttachedFilesystem?, additionalMounts: [Mount]) throws {
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, writableLayer: writableLayer, mounts: mounts)
_storage.withLock {
$0.containers[id] = container
}
}

Expand Down Expand Up @@ -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<letter>" 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 {
Expand All @@ -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)))
}
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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 {
Expand Down
Loading